🪻 distributed transcription service
thistle.dunkirk.sh
1import {
2 afterAll,
3 beforeAll,
4 beforeEach,
5 describe,
6 expect,
7 test,
8} from "bun:test";
9import db from "./db/schema";
10import { hashPasswordClient } from "./lib/client-auth";
11
12// Test server URL - uses port 3001 for testing to avoid conflicts
13const TEST_PORT = 3001;
14const BASE_URL = `http://localhost:${TEST_PORT}`;
15
16// Check if server is available
17let serverAvailable = false;
18
19beforeAll(async () => {
20 try {
21 const response = await fetch(`${BASE_URL}/api/health`, {
22 signal: AbortSignal.timeout(1000),
23 });
24 serverAvailable = response.ok || response.status === 404;
25 } catch {
26 console.warn(
27 `\n⚠️ Test server not running on port ${TEST_PORT}. Start it with:\n PORT=${TEST_PORT} bun run src/index.ts\n Then run tests in another terminal.\n`,
28 );
29 serverAvailable = false;
30 }
31});
32
33// Test user credentials
34const TEST_USER = {
35 email: "test@example.com",
36 password: "TestPassword123!",
37 name: "Test User",
38};
39
40const TEST_ADMIN = {
41 email: "admin@example.com",
42 password: "AdminPassword123!",
43 name: "Admin User",
44};
45
46const TEST_USER_2 = {
47 email: "test2@example.com",
48 password: "TestPassword456!",
49 name: "Test User 2",
50};
51
52// Helper to hash passwords like the client would
53async function clientHashPassword(
54 email: string,
55 password: string,
56): Promise<string> {
57 return await hashPasswordClient(password, email);
58}
59
60// Helper to extract session cookie
61function extractSessionCookie(response: Response): string {
62 const setCookie = response.headers.get("set-cookie");
63 if (!setCookie) throw new Error("No set-cookie header found");
64 const match = setCookie.match(/session=([^;]+)/);
65 if (!match) throw new Error("No session cookie found in set-cookie header");
66 return match[1];
67}
68
69// Helper to make authenticated requests
70function authRequest(
71 url: string,
72 sessionCookie: string,
73 options: RequestInit = {},
74): Promise<Response> {
75 return fetch(url, {
76 ...options,
77 headers: {
78 ...options.headers,
79 Cookie: `session=${sessionCookie}`,
80 },
81 });
82}
83
84// Cleanup helpers
85function cleanupTestData() {
86 // Delete test users and their related data (cascade will handle most of it)
87 // Include 'newemail%' to catch users whose emails were updated during tests
88 db.run(
89 "DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
90 );
91 db.run(
92 "DELETE FROM passkeys WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
93 );
94 db.run(
95 "DELETE FROM transcriptions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",
96 );
97 db.run(
98 "DELETE FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%'",
99 );
100
101 // Clear ALL rate limit data to prevent accumulation across tests
102 // (IP-based rate limits don't contain test/admin in the key)
103 db.run("DELETE FROM rate_limit_attempts");
104}
105
106beforeEach(() => {
107 if (serverAvailable) {
108 cleanupTestData();
109 }
110});
111
112afterAll(() => {
113 if (serverAvailable) {
114 cleanupTestData();
115 }
116});
117
118// Helper to skip tests if server is not available
119function serverTest(name: string, fn: () => void | Promise<void>) {
120 test(name, async () => {
121 if (!serverAvailable) {
122 console.log(`⏭️ Skipping: ${name} (server not running)`);
123 return;
124 }
125 await fn();
126 });
127}
128
129describe("API Endpoints - Authentication", () => {
130 describe("POST /api/auth/register", () => {
131 serverTest("should register a new user successfully", async () => {
132 const hashedPassword = await clientHashPassword(
133 TEST_USER.email,
134 TEST_USER.password,
135 );
136
137 const response = await fetch(`${BASE_URL}/api/auth/register`, {
138 method: "POST",
139 headers: { "Content-Type": "application/json" },
140 body: JSON.stringify({
141 email: TEST_USER.email,
142 password: hashedPassword,
143 name: TEST_USER.name,
144 }),
145 });
146
147 expect(response.status).toBe(200);
148 const data = await response.json();
149 expect(data.user).toBeDefined();
150 expect(data.user.email).toBe(TEST_USER.email);
151 expect(extractSessionCookie(response)).toBeTruthy();
152 });
153
154 serverTest("should reject registration with missing email", async () => {
155 const response = await fetch(`${BASE_URL}/api/auth/register`, {
156 method: "POST",
157 headers: { "Content-Type": "application/json" },
158 body: JSON.stringify({
159 password: "hashedpassword123456",
160 }),
161 });
162
163 expect(response.status).toBe(400);
164 const data = await response.json();
165 expect(data.error).toBe("Email and password required");
166 });
167
168 serverTest(
169 "should reject registration with invalid password format",
170 async () => {
171 const response = await fetch(`${BASE_URL}/api/auth/register`, {
172 method: "POST",
173 headers: { "Content-Type": "application/json" },
174 body: JSON.stringify({
175 email: TEST_USER.email,
176 password: "short",
177 }),
178 });
179
180 expect(response.status).toBe(400);
181 const data = await response.json();
182 expect(data.error).toBe("Invalid password format");
183 },
184 );
185
186 serverTest("should reject duplicate email registration", async () => {
187 const hashedPassword = await clientHashPassword(
188 TEST_USER.email,
189 TEST_USER.password,
190 );
191
192 // First registration
193 await fetch(`${BASE_URL}/api/auth/register`, {
194 method: "POST",
195 headers: { "Content-Type": "application/json" },
196 body: JSON.stringify({
197 email: TEST_USER.email,
198 password: hashedPassword,
199 name: TEST_USER.name,
200 }),
201 });
202
203 // Duplicate registration
204 const response = await fetch(`${BASE_URL}/api/auth/register`, {
205 method: "POST",
206 headers: { "Content-Type": "application/json" },
207 body: JSON.stringify({
208 email: TEST_USER.email,
209 password: hashedPassword,
210 name: TEST_USER.name,
211 }),
212 });
213
214 expect(response.status).toBe(400);
215 const data = await response.json();
216 expect(data.error).toBe("Email already registered");
217 });
218
219 serverTest("should enforce rate limiting on registration", async () => {
220 const hashedPassword = await clientHashPassword(
221 "test@example.com",
222 "password",
223 );
224
225 // Make registration attempts until rate limit is hit (limit is 5 per hour)
226 let rateLimitHit = false;
227 for (let i = 0; i < 10; i++) {
228 const response = await fetch(`${BASE_URL}/api/auth/register`, {
229 method: "POST",
230 headers: { "Content-Type": "application/json" },
231 body: JSON.stringify({
232 email: `test${i}@example.com`,
233 password: hashedPassword,
234 }),
235 });
236
237 if (response.status === 429) {
238 rateLimitHit = true;
239 break;
240 }
241 }
242
243 // Verify that rate limiting was triggered
244 expect(rateLimitHit).toBe(true);
245 });
246 });
247
248 describe("POST /api/auth/login", () => {
249 serverTest("should login successfully with valid credentials", async () => {
250 // Register user first
251 const hashedPassword = await clientHashPassword(
252 TEST_USER.email,
253 TEST_USER.password,
254 );
255 await fetch(`${BASE_URL}/api/auth/register`, {
256 method: "POST",
257 headers: { "Content-Type": "application/json" },
258 body: JSON.stringify({
259 email: TEST_USER.email,
260 password: hashedPassword,
261 name: TEST_USER.name,
262 }),
263 });
264
265 // Login
266 const response = await fetch(`${BASE_URL}/api/auth/login`, {
267 method: "POST",
268 headers: { "Content-Type": "application/json" },
269 body: JSON.stringify({
270 email: TEST_USER.email,
271 password: hashedPassword,
272 }),
273 });
274
275 expect(response.status).toBe(200);
276 const data = await response.json();
277 expect(data.user).toBeDefined();
278 expect(data.user.email).toBe(TEST_USER.email);
279 expect(extractSessionCookie(response)).toBeTruthy();
280 });
281
282 serverTest("should reject login with invalid credentials", async () => {
283 // Register user first
284 const hashedPassword = await clientHashPassword(
285 TEST_USER.email,
286 TEST_USER.password,
287 );
288 await fetch(`${BASE_URL}/api/auth/register`, {
289 method: "POST",
290 headers: { "Content-Type": "application/json" },
291 body: JSON.stringify({
292 email: TEST_USER.email,
293 password: hashedPassword,
294 }),
295 });
296
297 // Login with wrong password
298 const wrongPassword = await clientHashPassword(
299 TEST_USER.email,
300 "WrongPassword123!",
301 );
302 const response = await fetch(`${BASE_URL}/api/auth/login`, {
303 method: "POST",
304 headers: { "Content-Type": "application/json" },
305 body: JSON.stringify({
306 email: TEST_USER.email,
307 password: wrongPassword,
308 }),
309 });
310
311 expect(response.status).toBe(401);
312 const data = await response.json();
313 expect(data.error).toBe("Invalid email or password");
314 });
315
316 serverTest("should reject login with missing fields", async () => {
317 const response = await fetch(`${BASE_URL}/api/auth/login`, {
318 method: "POST",
319 headers: { "Content-Type": "application/json" },
320 body: JSON.stringify({
321 email: TEST_USER.email,
322 }),
323 });
324
325 expect(response.status).toBe(400);
326 const data = await response.json();
327 expect(data.error).toBe("Email and password required");
328 });
329
330 serverTest("should enforce rate limiting on login attempts", async () => {
331 const hashedPassword = await clientHashPassword(
332 TEST_USER.email,
333 TEST_USER.password,
334 );
335
336 // Make 11 login attempts (limit is 10 per 15 minutes per IP)
337 let rateLimitHit = false;
338 for (let i = 0; i < 11; i++) {
339 const response = await fetch(`${BASE_URL}/api/auth/login`, {
340 method: "POST",
341 headers: { "Content-Type": "application/json" },
342 body: JSON.stringify({
343 email: TEST_USER.email,
344 password: hashedPassword,
345 }),
346 });
347
348 if (response.status === 429) {
349 rateLimitHit = true;
350 break;
351 }
352 }
353
354 // Verify that rate limiting was triggered
355 expect(rateLimitHit).toBe(true);
356 });
357 });
358
359 describe("POST /api/auth/logout", () => {
360 serverTest("should logout successfully", async () => {
361 // Register and login
362 const hashedPassword = await clientHashPassword(
363 TEST_USER.email,
364 TEST_USER.password,
365 );
366 const loginResponse = await fetch(`${BASE_URL}/api/auth/register`, {
367 method: "POST",
368 headers: { "Content-Type": "application/json" },
369 body: JSON.stringify({
370 email: TEST_USER.email,
371 password: hashedPassword,
372 }),
373 });
374 const sessionCookie = extractSessionCookie(loginResponse);
375
376 // Logout
377 const response = await authRequest(
378 `${BASE_URL}/api/auth/logout`,
379 sessionCookie,
380 {
381 method: "POST",
382 },
383 );
384
385 expect(response.status).toBe(200);
386 const data = await response.json();
387 expect(data.success).toBe(true);
388
389 // Verify cookie is cleared
390 const setCookie = response.headers.get("set-cookie");
391 expect(setCookie).toContain("Max-Age=0");
392 });
393
394 serverTest("should logout even without valid session", async () => {
395 const response = await fetch(`${BASE_URL}/api/auth/logout`, {
396 method: "POST",
397 });
398
399 expect(response.status).toBe(200);
400 const data = await response.json();
401 expect(data.success).toBe(true);
402 });
403 });
404
405 describe("GET /api/auth/me", () => {
406 serverTest(
407 "should return current user info when authenticated",
408 async () => {
409 // Register user
410 const hashedPassword = await clientHashPassword(
411 TEST_USER.email,
412 TEST_USER.password,
413 );
414 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
415 method: "POST",
416 headers: { "Content-Type": "application/json" },
417 body: JSON.stringify({
418 email: TEST_USER.email,
419 password: hashedPassword,
420 name: TEST_USER.name,
421 }),
422 });
423 const sessionCookie = extractSessionCookie(registerResponse);
424
425 // Get current user
426 const response = await authRequest(
427 `${BASE_URL}/api/auth/me`,
428 sessionCookie,
429 );
430
431 expect(response.status).toBe(200);
432 const data = await response.json();
433 expect(data.email).toBe(TEST_USER.email);
434 expect(data.name).toBe(TEST_USER.name);
435 expect(data.role).toBeDefined();
436 },
437 );
438
439 serverTest("should return 401 when not authenticated", async () => {
440 const response = await fetch(`${BASE_URL}/api/auth/me`);
441
442 expect(response.status).toBe(401);
443 const data = await response.json();
444 expect(data.error).toBe("Not authenticated");
445 });
446
447 serverTest("should return 401 with invalid session", async () => {
448 const response = await authRequest(
449 `${BASE_URL}/api/auth/me`,
450 "invalid-session",
451 );
452
453 expect(response.status).toBe(401);
454 const data = await response.json();
455 expect(data.error).toBe("Invalid session");
456 });
457 });
458});
459
460describe("API Endpoints - Session Management", () => {
461 describe("GET /api/sessions", () => {
462 serverTest("should return user sessions", async () => {
463 // Register user
464 const hashedPassword = await clientHashPassword(
465 TEST_USER.email,
466 TEST_USER.password,
467 );
468 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
469 method: "POST",
470 headers: { "Content-Type": "application/json" },
471 body: JSON.stringify({
472 email: TEST_USER.email,
473 password: hashedPassword,
474 }),
475 });
476 const sessionCookie = extractSessionCookie(registerResponse);
477
478 // Get sessions
479 const response = await authRequest(
480 `${BASE_URL}/api/sessions`,
481 sessionCookie,
482 );
483
484 expect(response.status).toBe(200);
485 const data = await response.json();
486 expect(data.sessions).toBeDefined();
487 expect(data.sessions.length).toBeGreaterThan(0);
488 expect(data.sessions[0]).toHaveProperty("id");
489 expect(data.sessions[0]).toHaveProperty("ip_address");
490 expect(data.sessions[0]).toHaveProperty("user_agent");
491 });
492
493 serverTest("should require authentication", async () => {
494 const response = await fetch(`${BASE_URL}/api/sessions`);
495
496 expect(response.status).toBe(401);
497 });
498 });
499
500 describe("DELETE /api/sessions", () => {
501 serverTest("should delete specific session", async () => {
502 // Register user and create multiple sessions
503 const hashedPassword = await clientHashPassword(
504 TEST_USER.email,
505 TEST_USER.password,
506 );
507 const session1Response = await fetch(`${BASE_URL}/api/auth/register`, {
508 method: "POST",
509 headers: { "Content-Type": "application/json" },
510 body: JSON.stringify({
511 email: TEST_USER.email,
512 password: hashedPassword,
513 }),
514 });
515 const session1Cookie = extractSessionCookie(session1Response);
516
517 const session2Response = await fetch(`${BASE_URL}/api/auth/login`, {
518 method: "POST",
519 headers: { "Content-Type": "application/json" },
520 body: JSON.stringify({
521 email: TEST_USER.email,
522 password: hashedPassword,
523 }),
524 });
525 const session2Cookie = extractSessionCookie(session2Response);
526
527 // Get sessions list
528 const sessionsResponse = await authRequest(
529 `${BASE_URL}/api/sessions`,
530 session1Cookie,
531 );
532 const sessionsData = await sessionsResponse.json();
533 const targetSessionId = sessionsData.sessions.find(
534 (s: { id: string }) => s.id === session2Cookie,
535 )?.id;
536
537 // Delete session 2
538 const response = await authRequest(
539 `${BASE_URL}/api/sessions`,
540 session1Cookie,
541 {
542 method: "DELETE",
543 headers: { "Content-Type": "application/json" },
544 body: JSON.stringify({ sessionId: targetSessionId }),
545 },
546 );
547
548 expect(response.status).toBe(200);
549 const data = await response.json();
550 expect(data.success).toBe(true);
551
552 // Verify session 2 is deleted
553 const verifyResponse = await authRequest(
554 `${BASE_URL}/api/auth/me`,
555 session2Cookie,
556 );
557 expect(verifyResponse.status).toBe(401);
558 });
559
560 serverTest("should not delete another user's session", async () => {
561 // Register two users
562 const hashedPassword1 = await clientHashPassword(
563 TEST_USER.email,
564 TEST_USER.password,
565 );
566 const user1Response = await fetch(`${BASE_URL}/api/auth/register`, {
567 method: "POST",
568 headers: { "Content-Type": "application/json" },
569 body: JSON.stringify({
570 email: TEST_USER.email,
571 password: hashedPassword1,
572 }),
573 });
574 const user1Cookie = extractSessionCookie(user1Response);
575
576 const hashedPassword2 = await clientHashPassword(
577 TEST_USER_2.email,
578 TEST_USER_2.password,
579 );
580 const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
581 method: "POST",
582 headers: { "Content-Type": "application/json" },
583 body: JSON.stringify({
584 email: TEST_USER_2.email,
585 password: hashedPassword2,
586 }),
587 });
588 const user2Cookie = extractSessionCookie(user2Response);
589
590 // Try to delete user2's session using user1's credentials
591 const response = await authRequest(
592 `${BASE_URL}/api/sessions`,
593 user1Cookie,
594 {
595 method: "DELETE",
596 headers: { "Content-Type": "application/json" },
597 body: JSON.stringify({ sessionId: user2Cookie }),
598 },
599 );
600
601 expect(response.status).toBe(404);
602 });
603
604 serverTest("should not delete current session", async () => {
605 // Register user
606 const hashedPassword = await clientHashPassword(
607 TEST_USER.email,
608 TEST_USER.password,
609 );
610 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
611 method: "POST",
612 headers: { "Content-Type": "application/json" },
613 body: JSON.stringify({
614 email: TEST_USER.email,
615 password: hashedPassword,
616 }),
617 });
618 const sessionCookie = extractSessionCookie(registerResponse);
619
620 // Try to delete own current session
621 const response = await authRequest(
622 `${BASE_URL}/api/sessions`,
623 sessionCookie,
624 {
625 method: "DELETE",
626 headers: { "Content-Type": "application/json" },
627 body: JSON.stringify({ sessionId: sessionCookie }),
628 },
629 );
630
631 expect(response.status).toBe(400);
632 const data = await response.json();
633 expect(data.error).toContain("Cannot kill current session");
634 });
635 });
636});
637
638describe("API Endpoints - User Management", () => {
639 describe("DELETE /api/user", () => {
640 serverTest("should delete user account", async () => {
641 // Register user
642 const hashedPassword = await clientHashPassword(
643 TEST_USER.email,
644 TEST_USER.password,
645 );
646 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
647 method: "POST",
648 headers: { "Content-Type": "application/json" },
649 body: JSON.stringify({
650 email: TEST_USER.email,
651 password: hashedPassword,
652 }),
653 });
654 const sessionCookie = extractSessionCookie(registerResponse);
655
656 // Delete account
657 const response = await authRequest(
658 `${BASE_URL}/api/user`,
659 sessionCookie,
660 {
661 method: "DELETE",
662 },
663 );
664
665 expect(response.status).toBe(200);
666 const data = await response.json();
667 expect(data.success).toBe(true);
668
669 // Verify user is deleted
670 const verifyResponse = await authRequest(
671 `${BASE_URL}/api/auth/me`,
672 sessionCookie,
673 );
674 expect(verifyResponse.status).toBe(401);
675 });
676
677 serverTest("should require authentication", async () => {
678 const response = await fetch(`${BASE_URL}/api/user`, {
679 method: "DELETE",
680 });
681
682 expect(response.status).toBe(401);
683 });
684 });
685
686 describe("PUT /api/user/email", () => {
687 serverTest("should update user email", async () => {
688 // Register user
689 const hashedPassword = await clientHashPassword(
690 TEST_USER.email,
691 TEST_USER.password,
692 );
693 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
694 method: "POST",
695 headers: { "Content-Type": "application/json" },
696 body: JSON.stringify({
697 email: TEST_USER.email,
698 password: hashedPassword,
699 }),
700 });
701 const sessionCookie = extractSessionCookie(registerResponse);
702
703 // Update email
704 const newEmail = "newemail@example.com";
705 const response = await authRequest(
706 `${BASE_URL}/api/user/email`,
707 sessionCookie,
708 {
709 method: "PUT",
710 headers: { "Content-Type": "application/json" },
711 body: JSON.stringify({ email: newEmail }),
712 },
713 );
714
715 expect(response.status).toBe(200);
716 const data = await response.json();
717 expect(data.success).toBe(true);
718
719 // Verify email updated
720 const meResponse = await authRequest(
721 `${BASE_URL}/api/auth/me`,
722 sessionCookie,
723 );
724 const meData = await meResponse.json();
725 expect(meData.email).toBe(newEmail);
726 });
727
728 serverTest("should reject duplicate email", async () => {
729 // Register two users
730 const hashedPassword1 = await clientHashPassword(
731 TEST_USER.email,
732 TEST_USER.password,
733 );
734 await fetch(`${BASE_URL}/api/auth/register`, {
735 method: "POST",
736 headers: { "Content-Type": "application/json" },
737 body: JSON.stringify({
738 email: TEST_USER.email,
739 password: hashedPassword1,
740 }),
741 });
742
743 const hashedPassword2 = await clientHashPassword(
744 TEST_USER_2.email,
745 TEST_USER_2.password,
746 );
747 const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
748 method: "POST",
749 headers: { "Content-Type": "application/json" },
750 body: JSON.stringify({
751 email: TEST_USER_2.email,
752 password: hashedPassword2,
753 }),
754 });
755 const user2Cookie = extractSessionCookie(user2Response);
756
757 // Try to update user2's email to user1's email
758 const response = await authRequest(
759 `${BASE_URL}/api/user/email`,
760 user2Cookie,
761 {
762 method: "PUT",
763 headers: { "Content-Type": "application/json" },
764 body: JSON.stringify({ email: TEST_USER.email }),
765 },
766 );
767
768 expect(response.status).toBe(400);
769 const data = await response.json();
770 expect(data.error).toBe("Email already in use");
771 });
772 });
773
774 describe("PUT /api/user/password", () => {
775 serverTest("should update user password", async () => {
776 // Register user
777 const hashedPassword = await clientHashPassword(
778 TEST_USER.email,
779 TEST_USER.password,
780 );
781 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
782 method: "POST",
783 headers: { "Content-Type": "application/json" },
784 body: JSON.stringify({
785 email: TEST_USER.email,
786 password: hashedPassword,
787 }),
788 });
789 const sessionCookie = extractSessionCookie(registerResponse);
790
791 // Update password
792 const newPassword = await clientHashPassword(
793 TEST_USER.email,
794 "NewPassword123!",
795 );
796 const response = await authRequest(
797 `${BASE_URL}/api/user/password`,
798 sessionCookie,
799 {
800 method: "PUT",
801 headers: { "Content-Type": "application/json" },
802 body: JSON.stringify({ password: newPassword }),
803 },
804 );
805
806 expect(response.status).toBe(200);
807 const data = await response.json();
808 expect(data.success).toBe(true);
809
810 // Verify can login with new password
811 const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
812 method: "POST",
813 headers: { "Content-Type": "application/json" },
814 body: JSON.stringify({
815 email: TEST_USER.email,
816 password: newPassword,
817 }),
818 });
819 expect(loginResponse.status).toBe(200);
820 });
821
822 serverTest("should reject invalid password format", async () => {
823 // Register user
824 const hashedPassword = await clientHashPassword(
825 TEST_USER.email,
826 TEST_USER.password,
827 );
828 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
829 method: "POST",
830 headers: { "Content-Type": "application/json" },
831 body: JSON.stringify({
832 email: TEST_USER.email,
833 password: hashedPassword,
834 }),
835 });
836 const sessionCookie = extractSessionCookie(registerResponse);
837
838 // Try to update with invalid format
839 const response = await authRequest(
840 `${BASE_URL}/api/user/password`,
841 sessionCookie,
842 {
843 method: "PUT",
844 headers: { "Content-Type": "application/json" },
845 body: JSON.stringify({ password: "short" }),
846 },
847 );
848
849 expect(response.status).toBe(400);
850 const data = await response.json();
851 expect(data.error).toBe("Invalid password format");
852 });
853 });
854
855 describe("PUT /api/user/name", () => {
856 serverTest("should update user name", async () => {
857 // Register user
858 const hashedPassword = await clientHashPassword(
859 TEST_USER.email,
860 TEST_USER.password,
861 );
862 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
863 method: "POST",
864 headers: { "Content-Type": "application/json" },
865 body: JSON.stringify({
866 email: TEST_USER.email,
867 password: hashedPassword,
868 name: TEST_USER.name,
869 }),
870 });
871 const sessionCookie = extractSessionCookie(registerResponse);
872
873 // Update name
874 const newName = "Updated Name";
875 const response = await authRequest(
876 `${BASE_URL}/api/user/name`,
877 sessionCookie,
878 {
879 method: "PUT",
880 headers: { "Content-Type": "application/json" },
881 body: JSON.stringify({ name: newName }),
882 },
883 );
884
885 expect(response.status).toBe(200);
886 const data = await response.json();
887 expect(data.success).toBe(true);
888
889 // Verify name updated
890 const meResponse = await authRequest(
891 `${BASE_URL}/api/auth/me`,
892 sessionCookie,
893 );
894 const meData = await meResponse.json();
895 expect(meData.name).toBe(newName);
896 });
897
898 serverTest("should reject missing name", async () => {
899 // Register user
900 const hashedPassword = await clientHashPassword(
901 TEST_USER.email,
902 TEST_USER.password,
903 );
904 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
905 method: "POST",
906 headers: { "Content-Type": "application/json" },
907 body: JSON.stringify({
908 email: TEST_USER.email,
909 password: hashedPassword,
910 }),
911 });
912 const sessionCookie = extractSessionCookie(registerResponse);
913
914 const response = await authRequest(
915 `${BASE_URL}/api/user/name`,
916 sessionCookie,
917 {
918 method: "PUT",
919 headers: { "Content-Type": "application/json" },
920 body: JSON.stringify({}),
921 },
922 );
923
924 expect(response.status).toBe(400);
925 });
926 });
927
928 describe("PUT /api/user/avatar", () => {
929 serverTest("should update user avatar", async () => {
930 // Register user
931 const hashedPassword = await clientHashPassword(
932 TEST_USER.email,
933 TEST_USER.password,
934 );
935 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
936 method: "POST",
937 headers: { "Content-Type": "application/json" },
938 body: JSON.stringify({
939 email: TEST_USER.email,
940 password: hashedPassword,
941 }),
942 });
943 const sessionCookie = extractSessionCookie(registerResponse);
944
945 // Update avatar
946 const newAvatar = "👨💻";
947 const response = await authRequest(
948 `${BASE_URL}/api/user/avatar`,
949 sessionCookie,
950 {
951 method: "PUT",
952 headers: { "Content-Type": "application/json" },
953 body: JSON.stringify({ avatar: newAvatar }),
954 },
955 );
956
957 expect(response.status).toBe(200);
958 const data = await response.json();
959 expect(data.success).toBe(true);
960
961 // Verify avatar updated
962 const meResponse = await authRequest(
963 `${BASE_URL}/api/auth/me`,
964 sessionCookie,
965 );
966 const meData = await meResponse.json();
967 expect(meData.avatar).toBe(newAvatar);
968 });
969 });
970});
971
972describe("API Endpoints - Health", () => {
973 describe("GET /api/health", () => {
974 serverTest(
975 "should return service health status with details",
976 async () => {
977 const response = await fetch(`${BASE_URL}/api/health`);
978
979 expect(response.status).toBe(200);
980 const data = await response.json();
981 expect(data).toHaveProperty("status");
982 expect(data).toHaveProperty("timestamp");
983 expect(data).toHaveProperty("services");
984 expect(data.services).toHaveProperty("database");
985 expect(data.services).toHaveProperty("whisper");
986 expect(data.services).toHaveProperty("storage");
987 },
988 );
989 });
990});
991
992describe("API Endpoints - Transcriptions", () => {
993 describe("GET /api/transcriptions", () => {
994 serverTest("should return user transcriptions", async () => {
995 // Register user
996 const hashedPassword = await clientHashPassword(
997 TEST_USER.email,
998 TEST_USER.password,
999 );
1000 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1001 method: "POST",
1002 headers: { "Content-Type": "application/json" },
1003 body: JSON.stringify({
1004 email: TEST_USER.email,
1005 password: hashedPassword,
1006 }),
1007 });
1008 const sessionCookie = extractSessionCookie(registerResponse);
1009
1010 // Get transcriptions
1011 const response = await authRequest(
1012 `${BASE_URL}/api/transcriptions`,
1013 sessionCookie,
1014 );
1015
1016 expect(response.status).toBe(200);
1017 const data = await response.json();
1018 expect(data.jobs).toBeDefined();
1019 expect(Array.isArray(data.jobs)).toBe(true);
1020 });
1021
1022 serverTest("should require authentication", async () => {
1023 const response = await fetch(`${BASE_URL}/api/transcriptions`);
1024
1025 expect(response.status).toBe(401);
1026 });
1027 });
1028
1029 describe("POST /api/transcriptions", () => {
1030 serverTest("should upload audio file and start transcription", async () => {
1031 // Register user
1032 const hashedPassword = await clientHashPassword(
1033 TEST_USER.email,
1034 TEST_USER.password,
1035 );
1036 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1037 method: "POST",
1038 headers: { "Content-Type": "application/json" },
1039 body: JSON.stringify({
1040 email: TEST_USER.email,
1041 password: hashedPassword,
1042 }),
1043 });
1044 const sessionCookie = extractSessionCookie(registerResponse);
1045
1046 // Create a test audio file
1047 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
1048 const formData = new FormData();
1049 formData.append("audio", audioBlob, "test.mp3");
1050 formData.append("class_name", "Test Class");
1051
1052 // Upload
1053 const response = await authRequest(
1054 `${BASE_URL}/api/transcriptions`,
1055 sessionCookie,
1056 {
1057 method: "POST",
1058 body: formData,
1059 },
1060 );
1061
1062 expect(response.status).toBe(200);
1063 const data = await response.json();
1064 expect(data.id).toBeDefined();
1065 expect(data.message).toContain("Upload successful");
1066 });
1067
1068 serverTest("should reject non-audio files", async () => {
1069 // Register user
1070 const hashedPassword = await clientHashPassword(
1071 TEST_USER.email,
1072 TEST_USER.password,
1073 );
1074 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1075 method: "POST",
1076 headers: { "Content-Type": "application/json" },
1077 body: JSON.stringify({
1078 email: TEST_USER.email,
1079 password: hashedPassword,
1080 }),
1081 });
1082 const sessionCookie = extractSessionCookie(registerResponse);
1083
1084 // Try to upload non-audio file
1085 const textBlob = new Blob(["text file"], { type: "text/plain" });
1086 const formData = new FormData();
1087 formData.append("audio", textBlob, "test.txt");
1088
1089 const response = await authRequest(
1090 `${BASE_URL}/api/transcriptions`,
1091 sessionCookie,
1092 {
1093 method: "POST",
1094 body: formData,
1095 },
1096 );
1097
1098 expect(response.status).toBe(400);
1099 });
1100
1101 serverTest("should reject files exceeding size limit", async () => {
1102 // Register user
1103 const hashedPassword = await clientHashPassword(
1104 TEST_USER.email,
1105 TEST_USER.password,
1106 );
1107 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1108 method: "POST",
1109 headers: { "Content-Type": "application/json" },
1110 body: JSON.stringify({
1111 email: TEST_USER.email,
1112 password: hashedPassword,
1113 }),
1114 });
1115 const sessionCookie = extractSessionCookie(registerResponse);
1116
1117 // Create a file larger than 100MB (the actual limit)
1118 const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
1119 type: "audio/mp3",
1120 });
1121 const formData = new FormData();
1122 formData.append("audio", largeBlob, "large.mp3");
1123
1124 const response = await authRequest(
1125 `${BASE_URL}/api/transcriptions`,
1126 sessionCookie,
1127 {
1128 method: "POST",
1129 body: formData,
1130 },
1131 );
1132
1133 expect(response.status).toBe(400);
1134 const data = await response.json();
1135 expect(data.error).toContain("File size must be less than");
1136 });
1137
1138 serverTest("should require authentication", async () => {
1139 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
1140 const formData = new FormData();
1141 formData.append("audio", audioBlob, "test.mp3");
1142
1143 const response = await fetch(`${BASE_URL}/api/transcriptions`, {
1144 method: "POST",
1145 body: formData,
1146 });
1147
1148 expect(response.status).toBe(401);
1149 });
1150 });
1151});
1152
1153describe("API Endpoints - Admin", () => {
1154 let adminCookie: string;
1155 let userCookie: string;
1156 let userId: number;
1157
1158 beforeEach(async () => {
1159 if (!serverAvailable) return;
1160
1161 // Create admin user
1162 const adminHash = await clientHashPassword(
1163 TEST_ADMIN.email,
1164 TEST_ADMIN.password,
1165 );
1166 const adminResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1167 method: "POST",
1168 headers: { "Content-Type": "application/json" },
1169 body: JSON.stringify({
1170 email: TEST_ADMIN.email,
1171 password: adminHash,
1172 name: TEST_ADMIN.name,
1173 }),
1174 });
1175 adminCookie = extractSessionCookie(adminResponse);
1176
1177 // Manually set admin role in database
1178 db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
1179 TEST_ADMIN.email,
1180 ]);
1181
1182 // Create regular user
1183 const userHash = await clientHashPassword(
1184 TEST_USER.email,
1185 TEST_USER.password,
1186 );
1187 const userResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1188 method: "POST",
1189 headers: { "Content-Type": "application/json" },
1190 body: JSON.stringify({
1191 email: TEST_USER.email,
1192 password: userHash,
1193 name: TEST_USER.name,
1194 }),
1195 });
1196 userCookie = extractSessionCookie(userResponse);
1197
1198 // Get user ID
1199 const userIdResult = db
1200 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
1201 .get(TEST_USER.email);
1202 userId = userIdResult?.id;
1203 });
1204
1205 describe("GET /api/admin/users", () => {
1206 serverTest("should return all users for admin", async () => {
1207 const response = await authRequest(
1208 `${BASE_URL}/api/admin/users`,
1209 adminCookie,
1210 );
1211
1212 expect(response.status).toBe(200);
1213 const data = await response.json();
1214 expect(Array.isArray(data)).toBe(true);
1215 expect(data.length).toBeGreaterThan(0);
1216 });
1217
1218 serverTest("should reject non-admin users", async () => {
1219 const response = await authRequest(
1220 `${BASE_URL}/api/admin/users`,
1221 userCookie,
1222 );
1223
1224 expect(response.status).toBe(403);
1225 });
1226
1227 serverTest("should require authentication", async () => {
1228 const response = await fetch(`${BASE_URL}/api/admin/users`);
1229
1230 expect(response.status).toBe(401);
1231 });
1232 });
1233
1234 describe("GET /api/admin/transcriptions", () => {
1235 serverTest("should return all transcriptions for admin", async () => {
1236 const response = await authRequest(
1237 `${BASE_URL}/api/admin/transcriptions`,
1238 adminCookie,
1239 );
1240
1241 expect(response.status).toBe(200);
1242 const data = await response.json();
1243 expect(Array.isArray(data)).toBe(true);
1244 });
1245
1246 serverTest("should reject non-admin users", async () => {
1247 const response = await authRequest(
1248 `${BASE_URL}/api/admin/transcriptions`,
1249 userCookie,
1250 );
1251
1252 expect(response.status).toBe(403);
1253 });
1254 });
1255
1256 describe("DELETE /api/admin/users/:id", () => {
1257 serverTest("should delete user as admin", async () => {
1258 const response = await authRequest(
1259 `${BASE_URL}/api/admin/users/${userId}`,
1260 adminCookie,
1261 {
1262 method: "DELETE",
1263 },
1264 );
1265
1266 expect(response.status).toBe(200);
1267 const data = await response.json();
1268 expect(data.success).toBe(true);
1269
1270 // Verify user is deleted
1271 const verifyResponse = await authRequest(
1272 `${BASE_URL}/api/auth/me`,
1273 userCookie,
1274 );
1275 expect(verifyResponse.status).toBe(401);
1276 });
1277
1278 serverTest("should reject non-admin users", async () => {
1279 const response = await authRequest(
1280 `${BASE_URL}/api/admin/users/${userId}`,
1281 userCookie,
1282 {
1283 method: "DELETE",
1284 },
1285 );
1286
1287 expect(response.status).toBe(403);
1288 });
1289 });
1290
1291 describe("PUT /api/admin/users/:id/role", () => {
1292 serverTest("should update user role as admin", async () => {
1293 const response = await authRequest(
1294 `${BASE_URL}/api/admin/users/${userId}/role`,
1295 adminCookie,
1296 {
1297 method: "PUT",
1298 headers: { "Content-Type": "application/json" },
1299 body: JSON.stringify({ role: "admin" }),
1300 },
1301 );
1302
1303 expect(response.status).toBe(200);
1304 const data = await response.json();
1305 expect(data.success).toBe(true);
1306
1307 // Verify role updated
1308 const meResponse = await authRequest(
1309 `${BASE_URL}/api/auth/me`,
1310 userCookie,
1311 );
1312 const meData = await meResponse.json();
1313 expect(meData.role).toBe("admin");
1314 });
1315
1316 serverTest("should reject invalid roles", async () => {
1317 const response = await authRequest(
1318 `${BASE_URL}/api/admin/users/${userId}/role`,
1319 adminCookie,
1320 {
1321 method: "PUT",
1322 headers: { "Content-Type": "application/json" },
1323 body: JSON.stringify({ role: "superadmin" }),
1324 },
1325 );
1326
1327 expect(response.status).toBe(400);
1328 });
1329 });
1330
1331 describe("GET /api/admin/users/:id/details", () => {
1332 serverTest("should return user details for admin", async () => {
1333 const response = await authRequest(
1334 `${BASE_URL}/api/admin/users/${userId}/details`,
1335 adminCookie,
1336 );
1337
1338 expect(response.status).toBe(200);
1339 const data = await response.json();
1340 expect(data.id).toBe(userId);
1341 expect(data.email).toBe(TEST_USER.email);
1342 expect(data).toHaveProperty("passkeys");
1343 expect(data).toHaveProperty("sessions");
1344 });
1345
1346 serverTest("should reject non-admin users", async () => {
1347 const response = await authRequest(
1348 `${BASE_URL}/api/admin/users/${userId}/details`,
1349 userCookie,
1350 );
1351
1352 expect(response.status).toBe(403);
1353 });
1354 });
1355
1356 describe("PUT /api/admin/users/:id/name", () => {
1357 serverTest("should update user name as admin", async () => {
1358 const newName = "Admin Updated Name";
1359 const response = await authRequest(
1360 `${BASE_URL}/api/admin/users/${userId}/name`,
1361 adminCookie,
1362 {
1363 method: "PUT",
1364 headers: { "Content-Type": "application/json" },
1365 body: JSON.stringify({ name: newName }),
1366 },
1367 );
1368
1369 expect(response.status).toBe(200);
1370 const data = await response.json();
1371 expect(data.success).toBe(true);
1372 });
1373
1374 serverTest("should reject empty names", async () => {
1375 const response = await authRequest(
1376 `${BASE_URL}/api/admin/users/${userId}/name`,
1377 adminCookie,
1378 {
1379 method: "PUT",
1380 headers: { "Content-Type": "application/json" },
1381 body: JSON.stringify({ name: "" }),
1382 },
1383 );
1384
1385 expect(response.status).toBe(400);
1386 });
1387 });
1388
1389 describe("PUT /api/admin/users/:id/email", () => {
1390 serverTest("should update user email as admin", async () => {
1391 const newEmail = "newemail@admin.com";
1392 const response = await authRequest(
1393 `${BASE_URL}/api/admin/users/${userId}/email`,
1394 adminCookie,
1395 {
1396 method: "PUT",
1397 headers: { "Content-Type": "application/json" },
1398 body: JSON.stringify({ email: newEmail }),
1399 },
1400 );
1401
1402 expect(response.status).toBe(200);
1403 const data = await response.json();
1404 expect(data.success).toBe(true);
1405 });
1406
1407 serverTest("should reject duplicate emails", async () => {
1408 const response = await authRequest(
1409 `${BASE_URL}/api/admin/users/${userId}/email`,
1410 adminCookie,
1411 {
1412 method: "PUT",
1413 headers: { "Content-Type": "application/json" },
1414 body: JSON.stringify({ email: TEST_ADMIN.email }),
1415 },
1416 );
1417
1418 expect(response.status).toBe(400);
1419 const data = await response.json();
1420 expect(data.error).toBe("Email already in use");
1421 });
1422 });
1423
1424 describe("GET /api/admin/users/:id/sessions", () => {
1425 serverTest("should return user sessions as admin", async () => {
1426 const response = await authRequest(
1427 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1428 adminCookie,
1429 );
1430
1431 expect(response.status).toBe(200);
1432 const data = await response.json();
1433 expect(Array.isArray(data)).toBe(true);
1434 });
1435 });
1436
1437 describe("DELETE /api/admin/users/:id/sessions", () => {
1438 serverTest("should delete all user sessions as admin", async () => {
1439 const response = await authRequest(
1440 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1441 adminCookie,
1442 {
1443 method: "DELETE",
1444 },
1445 );
1446
1447 expect(response.status).toBe(200);
1448 const data = await response.json();
1449 expect(data.success).toBe(true);
1450
1451 // Verify sessions are deleted
1452 const verifyResponse = await authRequest(
1453 `${BASE_URL}/api/auth/me`,
1454 userCookie,
1455 );
1456 expect(verifyResponse.status).toBe(401);
1457 });
1458 });
1459});
1460
1461describe("API Endpoints - Passkeys", () => {
1462 let sessionCookie: string;
1463
1464 beforeEach(async () => {
1465 if (!serverAvailable) return;
1466
1467 // Register user
1468 const hashedPassword = await clientHashPassword(
1469 TEST_USER.email,
1470 TEST_USER.password,
1471 );
1472 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1473 method: "POST",
1474 headers: { "Content-Type": "application/json" },
1475 body: JSON.stringify({
1476 email: TEST_USER.email,
1477 password: hashedPassword,
1478 }),
1479 });
1480 sessionCookie = extractSessionCookie(registerResponse);
1481 });
1482
1483 describe("GET /api/passkeys", () => {
1484 serverTest("should return user passkeys", async () => {
1485 const response = await authRequest(
1486 `${BASE_URL}/api/passkeys`,
1487 sessionCookie,
1488 );
1489
1490 expect(response.status).toBe(200);
1491 const data = await response.json();
1492 expect(data.passkeys).toBeDefined();
1493 expect(Array.isArray(data.passkeys)).toBe(true);
1494 });
1495
1496 serverTest("should require authentication", async () => {
1497 const response = await fetch(`${BASE_URL}/api/passkeys`);
1498
1499 expect(response.status).toBe(401);
1500 });
1501 });
1502
1503 describe("POST /api/passkeys/register/options", () => {
1504 serverTest(
1505 "should return registration options for authenticated user",
1506 async () => {
1507 const response = await authRequest(
1508 `${BASE_URL}/api/passkeys/register/options`,
1509 sessionCookie,
1510 {
1511 method: "POST",
1512 },
1513 );
1514
1515 expect(response.status).toBe(200);
1516 const data = await response.json();
1517 expect(data).toHaveProperty("challenge");
1518 expect(data).toHaveProperty("rp");
1519 expect(data).toHaveProperty("user");
1520 },
1521 );
1522
1523 serverTest("should require authentication", async () => {
1524 const response = await fetch(
1525 `${BASE_URL}/api/passkeys/register/options`,
1526 {
1527 method: "POST",
1528 },
1529 );
1530
1531 expect(response.status).toBe(401);
1532 });
1533 });
1534
1535 describe("POST /api/passkeys/authenticate/options", () => {
1536 serverTest("should return authentication options for email", async () => {
1537 const response = await fetch(
1538 `${BASE_URL}/api/passkeys/authenticate/options`,
1539 {
1540 method: "POST",
1541 headers: { "Content-Type": "application/json" },
1542 body: JSON.stringify({ email: TEST_USER.email }),
1543 },
1544 );
1545
1546 expect(response.status).toBe(200);
1547 const data = await response.json();
1548 expect(data).toHaveProperty("challenge");
1549 });
1550
1551 serverTest("should handle non-existent email", async () => {
1552 const response = await fetch(
1553 `${BASE_URL}/api/passkeys/authenticate/options`,
1554 {
1555 method: "POST",
1556 headers: { "Content-Type": "application/json" },
1557 body: JSON.stringify({ email: "nonexistent@example.com" }),
1558 },
1559 );
1560
1561 // Should still return options for privacy (don't leak user existence)
1562 expect([200, 404]).toContain(response.status);
1563 });
1564 });
1565});