🪻 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/transcriptions/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 - Transcriptions", () => {
973 describe("GET /api/transcriptions/health", () => {
974 serverTest(
975 "should return transcription service health status",
976 async () => {
977 const response = await fetch(`${BASE_URL}/api/transcriptions/health`);
978
979 expect(response.status).toBe(200);
980 const data = await response.json();
981 expect(data).toHaveProperty("available");
982 expect(typeof data.available).toBe("boolean");
983 },
984 );
985 });
986
987 describe("GET /api/transcriptions", () => {
988 serverTest("should return user transcriptions", async () => {
989 // Register user
990 const hashedPassword = await clientHashPassword(
991 TEST_USER.email,
992 TEST_USER.password,
993 );
994 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
995 method: "POST",
996 headers: { "Content-Type": "application/json" },
997 body: JSON.stringify({
998 email: TEST_USER.email,
999 password: hashedPassword,
1000 }),
1001 });
1002 const sessionCookie = extractSessionCookie(registerResponse);
1003
1004 // Get transcriptions
1005 const response = await authRequest(
1006 `${BASE_URL}/api/transcriptions`,
1007 sessionCookie,
1008 );
1009
1010 expect(response.status).toBe(200);
1011 const data = await response.json();
1012 expect(data.jobs).toBeDefined();
1013 expect(Array.isArray(data.jobs)).toBe(true);
1014 });
1015
1016 serverTest("should require authentication", async () => {
1017 const response = await fetch(`${BASE_URL}/api/transcriptions`);
1018
1019 expect(response.status).toBe(401);
1020 });
1021 });
1022
1023 describe("POST /api/transcriptions", () => {
1024 serverTest("should upload audio file and start transcription", async () => {
1025 // Register user
1026 const hashedPassword = await clientHashPassword(
1027 TEST_USER.email,
1028 TEST_USER.password,
1029 );
1030 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1031 method: "POST",
1032 headers: { "Content-Type": "application/json" },
1033 body: JSON.stringify({
1034 email: TEST_USER.email,
1035 password: hashedPassword,
1036 }),
1037 });
1038 const sessionCookie = extractSessionCookie(registerResponse);
1039
1040 // Create a test audio file
1041 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
1042 const formData = new FormData();
1043 formData.append("audio", audioBlob, "test.mp3");
1044 formData.append("class_name", "Test Class");
1045
1046 // Upload
1047 const response = await authRequest(
1048 `${BASE_URL}/api/transcriptions`,
1049 sessionCookie,
1050 {
1051 method: "POST",
1052 body: formData,
1053 },
1054 );
1055
1056 expect(response.status).toBe(200);
1057 const data = await response.json();
1058 expect(data.id).toBeDefined();
1059 expect(data.message).toContain("Upload successful");
1060 });
1061
1062 serverTest("should reject non-audio files", async () => {
1063 // Register user
1064 const hashedPassword = await clientHashPassword(
1065 TEST_USER.email,
1066 TEST_USER.password,
1067 );
1068 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1069 method: "POST",
1070 headers: { "Content-Type": "application/json" },
1071 body: JSON.stringify({
1072 email: TEST_USER.email,
1073 password: hashedPassword,
1074 }),
1075 });
1076 const sessionCookie = extractSessionCookie(registerResponse);
1077
1078 // Try to upload non-audio file
1079 const textBlob = new Blob(["text file"], { type: "text/plain" });
1080 const formData = new FormData();
1081 formData.append("audio", textBlob, "test.txt");
1082
1083 const response = await authRequest(
1084 `${BASE_URL}/api/transcriptions`,
1085 sessionCookie,
1086 {
1087 method: "POST",
1088 body: formData,
1089 },
1090 );
1091
1092 expect(response.status).toBe(400);
1093 });
1094
1095 serverTest("should reject files exceeding size limit", async () => {
1096 // Register user
1097 const hashedPassword = await clientHashPassword(
1098 TEST_USER.email,
1099 TEST_USER.password,
1100 );
1101 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1102 method: "POST",
1103 headers: { "Content-Type": "application/json" },
1104 body: JSON.stringify({
1105 email: TEST_USER.email,
1106 password: hashedPassword,
1107 }),
1108 });
1109 const sessionCookie = extractSessionCookie(registerResponse);
1110
1111 // Create a file larger than 100MB (the actual limit)
1112 const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
1113 type: "audio/mp3",
1114 });
1115 const formData = new FormData();
1116 formData.append("audio", largeBlob, "large.mp3");
1117
1118 const response = await authRequest(
1119 `${BASE_URL}/api/transcriptions`,
1120 sessionCookie,
1121 {
1122 method: "POST",
1123 body: formData,
1124 },
1125 );
1126
1127 expect(response.status).toBe(400);
1128 const data = await response.json();
1129 expect(data.error).toContain("File size must be less than");
1130 });
1131
1132 serverTest("should require authentication", async () => {
1133 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
1134 const formData = new FormData();
1135 formData.append("audio", audioBlob, "test.mp3");
1136
1137 const response = await fetch(`${BASE_URL}/api/transcriptions`, {
1138 method: "POST",
1139 body: formData,
1140 });
1141
1142 expect(response.status).toBe(401);
1143 });
1144 });
1145});
1146
1147describe("API Endpoints - Admin", () => {
1148 let adminCookie: string;
1149 let userCookie: string;
1150 let userId: number;
1151
1152 beforeEach(async () => {
1153 if (!serverAvailable) return;
1154
1155 // Create admin user
1156 const adminHash = await clientHashPassword(
1157 TEST_ADMIN.email,
1158 TEST_ADMIN.password,
1159 );
1160 const adminResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1161 method: "POST",
1162 headers: { "Content-Type": "application/json" },
1163 body: JSON.stringify({
1164 email: TEST_ADMIN.email,
1165 password: adminHash,
1166 name: TEST_ADMIN.name,
1167 }),
1168 });
1169 adminCookie = extractSessionCookie(adminResponse);
1170
1171 // Manually set admin role in database
1172 db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
1173 TEST_ADMIN.email,
1174 ]);
1175
1176 // Create regular user
1177 const userHash = await clientHashPassword(
1178 TEST_USER.email,
1179 TEST_USER.password,
1180 );
1181 const userResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1182 method: "POST",
1183 headers: { "Content-Type": "application/json" },
1184 body: JSON.stringify({
1185 email: TEST_USER.email,
1186 password: userHash,
1187 name: TEST_USER.name,
1188 }),
1189 });
1190 userCookie = extractSessionCookie(userResponse);
1191
1192 // Get user ID
1193 const userIdResult = db
1194 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
1195 .get(TEST_USER.email);
1196 userId = userIdResult?.id;
1197 });
1198
1199 describe("GET /api/admin/users", () => {
1200 serverTest("should return all users for admin", async () => {
1201 const response = await authRequest(
1202 `${BASE_URL}/api/admin/users`,
1203 adminCookie,
1204 );
1205
1206 expect(response.status).toBe(200);
1207 const data = await response.json();
1208 expect(Array.isArray(data)).toBe(true);
1209 expect(data.length).toBeGreaterThan(0);
1210 });
1211
1212 serverTest("should reject non-admin users", async () => {
1213 const response = await authRequest(
1214 `${BASE_URL}/api/admin/users`,
1215 userCookie,
1216 );
1217
1218 expect(response.status).toBe(403);
1219 });
1220
1221 serverTest("should require authentication", async () => {
1222 const response = await fetch(`${BASE_URL}/api/admin/users`);
1223
1224 expect(response.status).toBe(401);
1225 });
1226 });
1227
1228 describe("GET /api/admin/transcriptions", () => {
1229 serverTest("should return all transcriptions for admin", async () => {
1230 const response = await authRequest(
1231 `${BASE_URL}/api/admin/transcriptions`,
1232 adminCookie,
1233 );
1234
1235 expect(response.status).toBe(200);
1236 const data = await response.json();
1237 expect(Array.isArray(data)).toBe(true);
1238 });
1239
1240 serverTest("should reject non-admin users", async () => {
1241 const response = await authRequest(
1242 `${BASE_URL}/api/admin/transcriptions`,
1243 userCookie,
1244 );
1245
1246 expect(response.status).toBe(403);
1247 });
1248 });
1249
1250 describe("DELETE /api/admin/users/:id", () => {
1251 serverTest("should delete user as admin", async () => {
1252 const response = await authRequest(
1253 `${BASE_URL}/api/admin/users/${userId}`,
1254 adminCookie,
1255 {
1256 method: "DELETE",
1257 },
1258 );
1259
1260 expect(response.status).toBe(200);
1261 const data = await response.json();
1262 expect(data.success).toBe(true);
1263
1264 // Verify user is deleted
1265 const verifyResponse = await authRequest(
1266 `${BASE_URL}/api/auth/me`,
1267 userCookie,
1268 );
1269 expect(verifyResponse.status).toBe(401);
1270 });
1271
1272 serverTest("should reject non-admin users", async () => {
1273 const response = await authRequest(
1274 `${BASE_URL}/api/admin/users/${userId}`,
1275 userCookie,
1276 {
1277 method: "DELETE",
1278 },
1279 );
1280
1281 expect(response.status).toBe(403);
1282 });
1283 });
1284
1285 describe("PUT /api/admin/users/:id/role", () => {
1286 serverTest("should update user role as admin", async () => {
1287 const response = await authRequest(
1288 `${BASE_URL}/api/admin/users/${userId}/role`,
1289 adminCookie,
1290 {
1291 method: "PUT",
1292 headers: { "Content-Type": "application/json" },
1293 body: JSON.stringify({ role: "admin" }),
1294 },
1295 );
1296
1297 expect(response.status).toBe(200);
1298 const data = await response.json();
1299 expect(data.success).toBe(true);
1300
1301 // Verify role updated
1302 const meResponse = await authRequest(
1303 `${BASE_URL}/api/auth/me`,
1304 userCookie,
1305 );
1306 const meData = await meResponse.json();
1307 expect(meData.role).toBe("admin");
1308 });
1309
1310 serverTest("should reject invalid roles", async () => {
1311 const response = await authRequest(
1312 `${BASE_URL}/api/admin/users/${userId}/role`,
1313 adminCookie,
1314 {
1315 method: "PUT",
1316 headers: { "Content-Type": "application/json" },
1317 body: JSON.stringify({ role: "superadmin" }),
1318 },
1319 );
1320
1321 expect(response.status).toBe(400);
1322 });
1323 });
1324
1325 describe("GET /api/admin/users/:id/details", () => {
1326 serverTest("should return user details for admin", async () => {
1327 const response = await authRequest(
1328 `${BASE_URL}/api/admin/users/${userId}/details`,
1329 adminCookie,
1330 );
1331
1332 expect(response.status).toBe(200);
1333 const data = await response.json();
1334 expect(data.id).toBe(userId);
1335 expect(data.email).toBe(TEST_USER.email);
1336 expect(data).toHaveProperty("passkeys");
1337 expect(data).toHaveProperty("sessions");
1338 });
1339
1340 serverTest("should reject non-admin users", async () => {
1341 const response = await authRequest(
1342 `${BASE_URL}/api/admin/users/${userId}/details`,
1343 userCookie,
1344 );
1345
1346 expect(response.status).toBe(403);
1347 });
1348 });
1349
1350 describe("PUT /api/admin/users/:id/name", () => {
1351 serverTest("should update user name as admin", async () => {
1352 const newName = "Admin Updated Name";
1353 const response = await authRequest(
1354 `${BASE_URL}/api/admin/users/${userId}/name`,
1355 adminCookie,
1356 {
1357 method: "PUT",
1358 headers: { "Content-Type": "application/json" },
1359 body: JSON.stringify({ name: newName }),
1360 },
1361 );
1362
1363 expect(response.status).toBe(200);
1364 const data = await response.json();
1365 expect(data.success).toBe(true);
1366 });
1367
1368 serverTest("should reject empty names", async () => {
1369 const response = await authRequest(
1370 `${BASE_URL}/api/admin/users/${userId}/name`,
1371 adminCookie,
1372 {
1373 method: "PUT",
1374 headers: { "Content-Type": "application/json" },
1375 body: JSON.stringify({ name: "" }),
1376 },
1377 );
1378
1379 expect(response.status).toBe(400);
1380 });
1381 });
1382
1383 describe("PUT /api/admin/users/:id/email", () => {
1384 serverTest("should update user email as admin", async () => {
1385 const newEmail = "newemail@admin.com";
1386 const response = await authRequest(
1387 `${BASE_URL}/api/admin/users/${userId}/email`,
1388 adminCookie,
1389 {
1390 method: "PUT",
1391 headers: { "Content-Type": "application/json" },
1392 body: JSON.stringify({ email: newEmail }),
1393 },
1394 );
1395
1396 expect(response.status).toBe(200);
1397 const data = await response.json();
1398 expect(data.success).toBe(true);
1399 });
1400
1401 serverTest("should reject duplicate emails", async () => {
1402 const response = await authRequest(
1403 `${BASE_URL}/api/admin/users/${userId}/email`,
1404 adminCookie,
1405 {
1406 method: "PUT",
1407 headers: { "Content-Type": "application/json" },
1408 body: JSON.stringify({ email: TEST_ADMIN.email }),
1409 },
1410 );
1411
1412 expect(response.status).toBe(400);
1413 const data = await response.json();
1414 expect(data.error).toBe("Email already in use");
1415 });
1416 });
1417
1418 describe("GET /api/admin/users/:id/sessions", () => {
1419 serverTest("should return user sessions as admin", async () => {
1420 const response = await authRequest(
1421 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1422 adminCookie,
1423 );
1424
1425 expect(response.status).toBe(200);
1426 const data = await response.json();
1427 expect(Array.isArray(data)).toBe(true);
1428 });
1429 });
1430
1431 describe("DELETE /api/admin/users/:id/sessions", () => {
1432 serverTest("should delete all user sessions as admin", async () => {
1433 const response = await authRequest(
1434 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1435 adminCookie,
1436 {
1437 method: "DELETE",
1438 },
1439 );
1440
1441 expect(response.status).toBe(200);
1442 const data = await response.json();
1443 expect(data.success).toBe(true);
1444
1445 // Verify sessions are deleted
1446 const verifyResponse = await authRequest(
1447 `${BASE_URL}/api/auth/me`,
1448 userCookie,
1449 );
1450 expect(verifyResponse.status).toBe(401);
1451 });
1452 });
1453});
1454
1455describe("API Endpoints - Passkeys", () => {
1456 let sessionCookie: string;
1457
1458 beforeEach(async () => {
1459 if (!serverAvailable) return;
1460
1461 // Register user
1462 const hashedPassword = await clientHashPassword(
1463 TEST_USER.email,
1464 TEST_USER.password,
1465 );
1466 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1467 method: "POST",
1468 headers: { "Content-Type": "application/json" },
1469 body: JSON.stringify({
1470 email: TEST_USER.email,
1471 password: hashedPassword,
1472 }),
1473 });
1474 sessionCookie = extractSessionCookie(registerResponse);
1475 });
1476
1477 describe("GET /api/passkeys", () => {
1478 serverTest("should return user passkeys", async () => {
1479 const response = await authRequest(
1480 `${BASE_URL}/api/passkeys`,
1481 sessionCookie,
1482 );
1483
1484 expect(response.status).toBe(200);
1485 const data = await response.json();
1486 expect(data.passkeys).toBeDefined();
1487 expect(Array.isArray(data.passkeys)).toBe(true);
1488 });
1489
1490 serverTest("should require authentication", async () => {
1491 const response = await fetch(`${BASE_URL}/api/passkeys`);
1492
1493 expect(response.status).toBe(401);
1494 });
1495 });
1496
1497 describe("POST /api/passkeys/register/options", () => {
1498 serverTest(
1499 "should return registration options for authenticated user",
1500 async () => {
1501 const response = await authRequest(
1502 `${BASE_URL}/api/passkeys/register/options`,
1503 sessionCookie,
1504 {
1505 method: "POST",
1506 },
1507 );
1508
1509 expect(response.status).toBe(200);
1510 const data = await response.json();
1511 expect(data).toHaveProperty("challenge");
1512 expect(data).toHaveProperty("rp");
1513 expect(data).toHaveProperty("user");
1514 },
1515 );
1516
1517 serverTest("should require authentication", async () => {
1518 const response = await fetch(
1519 `${BASE_URL}/api/passkeys/register/options`,
1520 {
1521 method: "POST",
1522 },
1523 );
1524
1525 expect(response.status).toBe(401);
1526 });
1527 });
1528
1529 describe("POST /api/passkeys/authenticate/options", () => {
1530 serverTest("should return authentication options for email", async () => {
1531 const response = await fetch(
1532 `${BASE_URL}/api/passkeys/authenticate/options`,
1533 {
1534 method: "POST",
1535 headers: { "Content-Type": "application/json" },
1536 body: JSON.stringify({ email: TEST_USER.email }),
1537 },
1538 );
1539
1540 expect(response.status).toBe(200);
1541 const data = await response.json();
1542 expect(data).toHaveProperty("challenge");
1543 });
1544
1545 serverTest("should handle non-existent email", async () => {
1546 const response = await fetch(
1547 `${BASE_URL}/api/passkeys/authenticate/options`,
1548 {
1549 method: "POST",
1550 headers: { "Content-Type": "application/json" },
1551 body: JSON.stringify({ email: "nonexistent@example.com" }),
1552 },
1553 );
1554
1555 // Should still return options for privacy (don't leak user existence)
1556 expect([200, 404]).toContain(response.status);
1557 });
1558 });
1559});