🪻 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});
605
606describe("API Endpoints - User Management", () => {
607 describe("DELETE /api/user", () => {
608 serverTest("should delete user account", async () => {
609 // Register user
610 const hashedPassword = await clientHashPassword(
611 TEST_USER.email,
612 TEST_USER.password,
613 );
614 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
615 method: "POST",
616 headers: { "Content-Type": "application/json" },
617 body: JSON.stringify({
618 email: TEST_USER.email,
619 password: hashedPassword,
620 }),
621 });
622 const sessionCookie = extractSessionCookie(registerResponse);
623
624 // Delete account
625 const response = await authRequest(
626 `${BASE_URL}/api/user`,
627 sessionCookie,
628 {
629 method: "DELETE",
630 },
631 );
632
633 expect(response.status).toBe(200);
634 const data = await response.json();
635 expect(data.success).toBe(true);
636
637 // Verify user is deleted
638 const verifyResponse = await authRequest(
639 `${BASE_URL}/api/auth/me`,
640 sessionCookie,
641 );
642 expect(verifyResponse.status).toBe(401);
643 });
644
645 serverTest("should require authentication", async () => {
646 const response = await fetch(`${BASE_URL}/api/user`, {
647 method: "DELETE",
648 });
649
650 expect(response.status).toBe(401);
651 });
652 });
653
654 describe("PUT /api/user/email", () => {
655 serverTest("should update user email", async () => {
656 // Register user
657 const hashedPassword = await clientHashPassword(
658 TEST_USER.email,
659 TEST_USER.password,
660 );
661 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
662 method: "POST",
663 headers: { "Content-Type": "application/json" },
664 body: JSON.stringify({
665 email: TEST_USER.email,
666 password: hashedPassword,
667 }),
668 });
669 const sessionCookie = extractSessionCookie(registerResponse);
670
671 // Update email
672 const newEmail = "newemail@example.com";
673 const response = await authRequest(
674 `${BASE_URL}/api/user/email`,
675 sessionCookie,
676 {
677 method: "PUT",
678 headers: { "Content-Type": "application/json" },
679 body: JSON.stringify({ email: newEmail }),
680 },
681 );
682
683 expect(response.status).toBe(200);
684 const data = await response.json();
685 expect(data.success).toBe(true);
686
687 // Verify email updated
688 const meResponse = await authRequest(
689 `${BASE_URL}/api/auth/me`,
690 sessionCookie,
691 );
692 const meData = await meResponse.json();
693 expect(meData.email).toBe(newEmail);
694 });
695
696 serverTest("should reject duplicate email", async () => {
697 // Register two users
698 const hashedPassword1 = await clientHashPassword(
699 TEST_USER.email,
700 TEST_USER.password,
701 );
702 await fetch(`${BASE_URL}/api/auth/register`, {
703 method: "POST",
704 headers: { "Content-Type": "application/json" },
705 body: JSON.stringify({
706 email: TEST_USER.email,
707 password: hashedPassword1,
708 }),
709 });
710
711 const hashedPassword2 = await clientHashPassword(
712 TEST_USER_2.email,
713 TEST_USER_2.password,
714 );
715 const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
716 method: "POST",
717 headers: { "Content-Type": "application/json" },
718 body: JSON.stringify({
719 email: TEST_USER_2.email,
720 password: hashedPassword2,
721 }),
722 });
723 const user2Cookie = extractSessionCookie(user2Response);
724
725 // Try to update user2's email to user1's email
726 const response = await authRequest(
727 `${BASE_URL}/api/user/email`,
728 user2Cookie,
729 {
730 method: "PUT",
731 headers: { "Content-Type": "application/json" },
732 body: JSON.stringify({ email: TEST_USER.email }),
733 },
734 );
735
736 expect(response.status).toBe(400);
737 const data = await response.json();
738 expect(data.error).toBe("Email already in use");
739 });
740 });
741
742 describe("PUT /api/user/password", () => {
743 serverTest("should update user password", async () => {
744 // Register user
745 const hashedPassword = await clientHashPassword(
746 TEST_USER.email,
747 TEST_USER.password,
748 );
749 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
750 method: "POST",
751 headers: { "Content-Type": "application/json" },
752 body: JSON.stringify({
753 email: TEST_USER.email,
754 password: hashedPassword,
755 }),
756 });
757 const sessionCookie = extractSessionCookie(registerResponse);
758
759 // Update password
760 const newPassword = await clientHashPassword(
761 TEST_USER.email,
762 "NewPassword123!",
763 );
764 const response = await authRequest(
765 `${BASE_URL}/api/user/password`,
766 sessionCookie,
767 {
768 method: "PUT",
769 headers: { "Content-Type": "application/json" },
770 body: JSON.stringify({ password: newPassword }),
771 },
772 );
773
774 expect(response.status).toBe(200);
775 const data = await response.json();
776 expect(data.success).toBe(true);
777
778 // Verify can login with new password
779 const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
780 method: "POST",
781 headers: { "Content-Type": "application/json" },
782 body: JSON.stringify({
783 email: TEST_USER.email,
784 password: newPassword,
785 }),
786 });
787 expect(loginResponse.status).toBe(200);
788 });
789
790 serverTest("should reject invalid password format", async () => {
791 // Register user
792 const hashedPassword = await clientHashPassword(
793 TEST_USER.email,
794 TEST_USER.password,
795 );
796 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
797 method: "POST",
798 headers: { "Content-Type": "application/json" },
799 body: JSON.stringify({
800 email: TEST_USER.email,
801 password: hashedPassword,
802 }),
803 });
804 const sessionCookie = extractSessionCookie(registerResponse);
805
806 // Try to update with invalid format
807 const response = await authRequest(
808 `${BASE_URL}/api/user/password`,
809 sessionCookie,
810 {
811 method: "PUT",
812 headers: { "Content-Type": "application/json" },
813 body: JSON.stringify({ password: "short" }),
814 },
815 );
816
817 expect(response.status).toBe(400);
818 const data = await response.json();
819 expect(data.error).toBe("Invalid password format");
820 });
821 });
822
823 describe("PUT /api/user/name", () => {
824 serverTest("should update user name", async () => {
825 // Register user
826 const hashedPassword = await clientHashPassword(
827 TEST_USER.email,
828 TEST_USER.password,
829 );
830 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
831 method: "POST",
832 headers: { "Content-Type": "application/json" },
833 body: JSON.stringify({
834 email: TEST_USER.email,
835 password: hashedPassword,
836 name: TEST_USER.name,
837 }),
838 });
839 const sessionCookie = extractSessionCookie(registerResponse);
840
841 // Update name
842 const newName = "Updated Name";
843 const response = await authRequest(
844 `${BASE_URL}/api/user/name`,
845 sessionCookie,
846 {
847 method: "PUT",
848 headers: { "Content-Type": "application/json" },
849 body: JSON.stringify({ name: newName }),
850 },
851 );
852
853 expect(response.status).toBe(200);
854 const data = await response.json();
855 expect(data.success).toBe(true);
856
857 // Verify name updated
858 const meResponse = await authRequest(
859 `${BASE_URL}/api/auth/me`,
860 sessionCookie,
861 );
862 const meData = await meResponse.json();
863 expect(meData.name).toBe(newName);
864 });
865
866 serverTest("should reject missing name", async () => {
867 // Register user
868 const hashedPassword = await clientHashPassword(
869 TEST_USER.email,
870 TEST_USER.password,
871 );
872 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
873 method: "POST",
874 headers: { "Content-Type": "application/json" },
875 body: JSON.stringify({
876 email: TEST_USER.email,
877 password: hashedPassword,
878 }),
879 });
880 const sessionCookie = extractSessionCookie(registerResponse);
881
882 const response = await authRequest(
883 `${BASE_URL}/api/user/name`,
884 sessionCookie,
885 {
886 method: "PUT",
887 headers: { "Content-Type": "application/json" },
888 body: JSON.stringify({}),
889 },
890 );
891
892 expect(response.status).toBe(400);
893 });
894 });
895
896 describe("PUT /api/user/avatar", () => {
897 serverTest("should update user avatar", async () => {
898 // Register user
899 const hashedPassword = await clientHashPassword(
900 TEST_USER.email,
901 TEST_USER.password,
902 );
903 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
904 method: "POST",
905 headers: { "Content-Type": "application/json" },
906 body: JSON.stringify({
907 email: TEST_USER.email,
908 password: hashedPassword,
909 }),
910 });
911 const sessionCookie = extractSessionCookie(registerResponse);
912
913 // Update avatar
914 const newAvatar = "👨💻";
915 const response = await authRequest(
916 `${BASE_URL}/api/user/avatar`,
917 sessionCookie,
918 {
919 method: "PUT",
920 headers: { "Content-Type": "application/json" },
921 body: JSON.stringify({ avatar: newAvatar }),
922 },
923 );
924
925 expect(response.status).toBe(200);
926 const data = await response.json();
927 expect(data.success).toBe(true);
928
929 // Verify avatar updated
930 const meResponse = await authRequest(
931 `${BASE_URL}/api/auth/me`,
932 sessionCookie,
933 );
934 const meData = await meResponse.json();
935 expect(meData.avatar).toBe(newAvatar);
936 });
937 });
938});
939
940describe("API Endpoints - Transcriptions", () => {
941 describe("GET /api/transcriptions/health", () => {
942 serverTest(
943 "should return transcription service health status",
944 async () => {
945 const response = await fetch(`${BASE_URL}/api/transcriptions/health`);
946
947 expect(response.status).toBe(200);
948 const data = await response.json();
949 expect(data).toHaveProperty("available");
950 expect(typeof data.available).toBe("boolean");
951 },
952 );
953 });
954
955 describe("GET /api/transcriptions", () => {
956 serverTest("should return user transcriptions", async () => {
957 // Register user
958 const hashedPassword = await clientHashPassword(
959 TEST_USER.email,
960 TEST_USER.password,
961 );
962 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
963 method: "POST",
964 headers: { "Content-Type": "application/json" },
965 body: JSON.stringify({
966 email: TEST_USER.email,
967 password: hashedPassword,
968 }),
969 });
970 const sessionCookie = extractSessionCookie(registerResponse);
971
972 // Get transcriptions
973 const response = await authRequest(
974 `${BASE_URL}/api/transcriptions`,
975 sessionCookie,
976 );
977
978 expect(response.status).toBe(200);
979 const data = await response.json();
980 expect(data.jobs).toBeDefined();
981 expect(Array.isArray(data.jobs)).toBe(true);
982 });
983
984 serverTest("should require authentication", async () => {
985 const response = await fetch(`${BASE_URL}/api/transcriptions`);
986
987 expect(response.status).toBe(401);
988 });
989 });
990
991 describe("POST /api/transcriptions", () => {
992 serverTest("should upload audio file and start transcription", async () => {
993 // Register user
994 const hashedPassword = await clientHashPassword(
995 TEST_USER.email,
996 TEST_USER.password,
997 );
998 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
999 method: "POST",
1000 headers: { "Content-Type": "application/json" },
1001 body: JSON.stringify({
1002 email: TEST_USER.email,
1003 password: hashedPassword,
1004 }),
1005 });
1006 const sessionCookie = extractSessionCookie(registerResponse);
1007
1008 // Create a test audio file
1009 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
1010 const formData = new FormData();
1011 formData.append("audio", audioBlob, "test.mp3");
1012 formData.append("class_name", "Test Class");
1013
1014 // Upload
1015 const response = await authRequest(
1016 `${BASE_URL}/api/transcriptions`,
1017 sessionCookie,
1018 {
1019 method: "POST",
1020 body: formData,
1021 },
1022 );
1023
1024 expect(response.status).toBe(200);
1025 const data = await response.json();
1026 expect(data.id).toBeDefined();
1027 expect(data.message).toContain("Upload successful");
1028 });
1029
1030 serverTest("should reject non-audio files", 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 // Try to upload non-audio file
1047 const textBlob = new Blob(["text file"], { type: "text/plain" });
1048 const formData = new FormData();
1049 formData.append("audio", textBlob, "test.txt");
1050
1051 const response = await authRequest(
1052 `${BASE_URL}/api/transcriptions`,
1053 sessionCookie,
1054 {
1055 method: "POST",
1056 body: formData,
1057 },
1058 );
1059
1060 expect(response.status).toBe(400);
1061 });
1062
1063 serverTest("should reject files exceeding size limit", async () => {
1064 // Register user
1065 const hashedPassword = await clientHashPassword(
1066 TEST_USER.email,
1067 TEST_USER.password,
1068 );
1069 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1070 method: "POST",
1071 headers: { "Content-Type": "application/json" },
1072 body: JSON.stringify({
1073 email: TEST_USER.email,
1074 password: hashedPassword,
1075 }),
1076 });
1077 const sessionCookie = extractSessionCookie(registerResponse);
1078
1079 // Create a file larger than 100MB (the actual limit)
1080 const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
1081 type: "audio/mp3",
1082 });
1083 const formData = new FormData();
1084 formData.append("audio", largeBlob, "large.mp3");
1085
1086 const response = await authRequest(
1087 `${BASE_URL}/api/transcriptions`,
1088 sessionCookie,
1089 {
1090 method: "POST",
1091 body: formData,
1092 },
1093 );
1094
1095 expect(response.status).toBe(400);
1096 const data = await response.json();
1097 expect(data.error).toContain("File size must be less than");
1098 });
1099
1100 serverTest("should require authentication", async () => {
1101 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
1102 const formData = new FormData();
1103 formData.append("audio", audioBlob, "test.mp3");
1104
1105 const response = await fetch(`${BASE_URL}/api/transcriptions`, {
1106 method: "POST",
1107 body: formData,
1108 });
1109
1110 expect(response.status).toBe(401);
1111 });
1112 });
1113});
1114
1115describe("API Endpoints - Admin", () => {
1116 let adminCookie: string;
1117 let userCookie: string;
1118 let userId: number;
1119
1120 beforeEach(async () => {
1121 if (!serverAvailable) return;
1122
1123 // Create admin user
1124 const adminHash = await clientHashPassword(
1125 TEST_ADMIN.email,
1126 TEST_ADMIN.password,
1127 );
1128 const adminResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1129 method: "POST",
1130 headers: { "Content-Type": "application/json" },
1131 body: JSON.stringify({
1132 email: TEST_ADMIN.email,
1133 password: adminHash,
1134 name: TEST_ADMIN.name,
1135 }),
1136 });
1137 adminCookie = extractSessionCookie(adminResponse);
1138
1139 // Manually set admin role in database
1140 db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
1141 TEST_ADMIN.email,
1142 ]);
1143
1144 // Create regular user
1145 const userHash = await clientHashPassword(
1146 TEST_USER.email,
1147 TEST_USER.password,
1148 );
1149 const userResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1150 method: "POST",
1151 headers: { "Content-Type": "application/json" },
1152 body: JSON.stringify({
1153 email: TEST_USER.email,
1154 password: userHash,
1155 name: TEST_USER.name,
1156 }),
1157 });
1158 userCookie = extractSessionCookie(userResponse);
1159
1160 // Get user ID
1161 const userIdResult = db
1162 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
1163 .get(TEST_USER.email);
1164 userId = userIdResult?.id;
1165 });
1166
1167 describe("GET /api/admin/users", () => {
1168 serverTest("should return all users for admin", async () => {
1169 const response = await authRequest(
1170 `${BASE_URL}/api/admin/users`,
1171 adminCookie,
1172 );
1173
1174 expect(response.status).toBe(200);
1175 const data = await response.json();
1176 expect(Array.isArray(data)).toBe(true);
1177 expect(data.length).toBeGreaterThan(0);
1178 });
1179
1180 serverTest("should reject non-admin users", async () => {
1181 const response = await authRequest(
1182 `${BASE_URL}/api/admin/users`,
1183 userCookie,
1184 );
1185
1186 expect(response.status).toBe(403);
1187 });
1188
1189 serverTest("should require authentication", async () => {
1190 const response = await fetch(`${BASE_URL}/api/admin/users`);
1191
1192 expect(response.status).toBe(401);
1193 });
1194 });
1195
1196 describe("GET /api/admin/transcriptions", () => {
1197 serverTest("should return all transcriptions for admin", async () => {
1198 const response = await authRequest(
1199 `${BASE_URL}/api/admin/transcriptions`,
1200 adminCookie,
1201 );
1202
1203 expect(response.status).toBe(200);
1204 const data = await response.json();
1205 expect(Array.isArray(data)).toBe(true);
1206 });
1207
1208 serverTest("should reject non-admin users", async () => {
1209 const response = await authRequest(
1210 `${BASE_URL}/api/admin/transcriptions`,
1211 userCookie,
1212 );
1213
1214 expect(response.status).toBe(403);
1215 });
1216 });
1217
1218 describe("DELETE /api/admin/users/:id", () => {
1219 serverTest("should delete user as admin", async () => {
1220 const response = await authRequest(
1221 `${BASE_URL}/api/admin/users/${userId}`,
1222 adminCookie,
1223 {
1224 method: "DELETE",
1225 },
1226 );
1227
1228 expect(response.status).toBe(200);
1229 const data = await response.json();
1230 expect(data.success).toBe(true);
1231
1232 // Verify user is deleted
1233 const verifyResponse = await authRequest(
1234 `${BASE_URL}/api/auth/me`,
1235 userCookie,
1236 );
1237 expect(verifyResponse.status).toBe(401);
1238 });
1239
1240 serverTest("should reject non-admin users", async () => {
1241 const response = await authRequest(
1242 `${BASE_URL}/api/admin/users/${userId}`,
1243 userCookie,
1244 {
1245 method: "DELETE",
1246 },
1247 );
1248
1249 expect(response.status).toBe(403);
1250 });
1251 });
1252
1253 describe("PUT /api/admin/users/:id/role", () => {
1254 serverTest("should update user role as admin", async () => {
1255 const response = await authRequest(
1256 `${BASE_URL}/api/admin/users/${userId}/role`,
1257 adminCookie,
1258 {
1259 method: "PUT",
1260 headers: { "Content-Type": "application/json" },
1261 body: JSON.stringify({ role: "admin" }),
1262 },
1263 );
1264
1265 expect(response.status).toBe(200);
1266 const data = await response.json();
1267 expect(data.success).toBe(true);
1268
1269 // Verify role updated
1270 const meResponse = await authRequest(
1271 `${BASE_URL}/api/auth/me`,
1272 userCookie,
1273 );
1274 const meData = await meResponse.json();
1275 expect(meData.role).toBe("admin");
1276 });
1277
1278 serverTest("should reject invalid roles", async () => {
1279 const response = await authRequest(
1280 `${BASE_URL}/api/admin/users/${userId}/role`,
1281 adminCookie,
1282 {
1283 method: "PUT",
1284 headers: { "Content-Type": "application/json" },
1285 body: JSON.stringify({ role: "superadmin" }),
1286 },
1287 );
1288
1289 expect(response.status).toBe(400);
1290 });
1291 });
1292
1293 describe("GET /api/admin/users/:id/details", () => {
1294 serverTest("should return user details for admin", async () => {
1295 const response = await authRequest(
1296 `${BASE_URL}/api/admin/users/${userId}/details`,
1297 adminCookie,
1298 );
1299
1300 expect(response.status).toBe(200);
1301 const data = await response.json();
1302 expect(data.id).toBe(userId);
1303 expect(data.email).toBe(TEST_USER.email);
1304 expect(data).toHaveProperty("passkeys");
1305 expect(data).toHaveProperty("sessions");
1306 });
1307
1308 serverTest("should reject non-admin users", async () => {
1309 const response = await authRequest(
1310 `${BASE_URL}/api/admin/users/${userId}/details`,
1311 userCookie,
1312 );
1313
1314 expect(response.status).toBe(403);
1315 });
1316 });
1317
1318 describe("PUT /api/admin/users/:id/name", () => {
1319 serverTest("should update user name as admin", async () => {
1320 const newName = "Admin Updated Name";
1321 const response = await authRequest(
1322 `${BASE_URL}/api/admin/users/${userId}/name`,
1323 adminCookie,
1324 {
1325 method: "PUT",
1326 headers: { "Content-Type": "application/json" },
1327 body: JSON.stringify({ name: newName }),
1328 },
1329 );
1330
1331 expect(response.status).toBe(200);
1332 const data = await response.json();
1333 expect(data.success).toBe(true);
1334 });
1335
1336 serverTest("should reject empty names", async () => {
1337 const response = await authRequest(
1338 `${BASE_URL}/api/admin/users/${userId}/name`,
1339 adminCookie,
1340 {
1341 method: "PUT",
1342 headers: { "Content-Type": "application/json" },
1343 body: JSON.stringify({ name: "" }),
1344 },
1345 );
1346
1347 expect(response.status).toBe(400);
1348 });
1349 });
1350
1351 describe("PUT /api/admin/users/:id/email", () => {
1352 serverTest("should update user email as admin", async () => {
1353 const newEmail = "newemail@admin.com";
1354 const response = await authRequest(
1355 `${BASE_URL}/api/admin/users/${userId}/email`,
1356 adminCookie,
1357 {
1358 method: "PUT",
1359 headers: { "Content-Type": "application/json" },
1360 body: JSON.stringify({ email: newEmail }),
1361 },
1362 );
1363
1364 expect(response.status).toBe(200);
1365 const data = await response.json();
1366 expect(data.success).toBe(true);
1367 });
1368
1369 serverTest("should reject duplicate emails", async () => {
1370 const response = await authRequest(
1371 `${BASE_URL}/api/admin/users/${userId}/email`,
1372 adminCookie,
1373 {
1374 method: "PUT",
1375 headers: { "Content-Type": "application/json" },
1376 body: JSON.stringify({ email: TEST_ADMIN.email }),
1377 },
1378 );
1379
1380 expect(response.status).toBe(400);
1381 const data = await response.json();
1382 expect(data.error).toBe("Email already in use");
1383 });
1384 });
1385
1386 describe("GET /api/admin/users/:id/sessions", () => {
1387 serverTest("should return user sessions as admin", async () => {
1388 const response = await authRequest(
1389 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1390 adminCookie,
1391 );
1392
1393 expect(response.status).toBe(200);
1394 const data = await response.json();
1395 expect(Array.isArray(data)).toBe(true);
1396 });
1397 });
1398
1399 describe("DELETE /api/admin/users/:id/sessions", () => {
1400 serverTest("should delete all user sessions as admin", async () => {
1401 const response = await authRequest(
1402 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1403 adminCookie,
1404 {
1405 method: "DELETE",
1406 },
1407 );
1408
1409 expect(response.status).toBe(200);
1410 const data = await response.json();
1411 expect(data.success).toBe(true);
1412
1413 // Verify sessions are deleted
1414 const verifyResponse = await authRequest(
1415 `${BASE_URL}/api/auth/me`,
1416 userCookie,
1417 );
1418 expect(verifyResponse.status).toBe(401);
1419 });
1420 });
1421});
1422
1423describe("API Endpoints - Passkeys", () => {
1424 let sessionCookie: string;
1425
1426 beforeEach(async () => {
1427 if (!serverAvailable) return;
1428
1429 // Register user
1430 const hashedPassword = await clientHashPassword(
1431 TEST_USER.email,
1432 TEST_USER.password,
1433 );
1434 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
1435 method: "POST",
1436 headers: { "Content-Type": "application/json" },
1437 body: JSON.stringify({
1438 email: TEST_USER.email,
1439 password: hashedPassword,
1440 }),
1441 });
1442 sessionCookie = extractSessionCookie(registerResponse);
1443 });
1444
1445 describe("GET /api/passkeys", () => {
1446 serverTest("should return user passkeys", async () => {
1447 const response = await authRequest(
1448 `${BASE_URL}/api/passkeys`,
1449 sessionCookie,
1450 );
1451
1452 expect(response.status).toBe(200);
1453 const data = await response.json();
1454 expect(data.passkeys).toBeDefined();
1455 expect(Array.isArray(data.passkeys)).toBe(true);
1456 });
1457
1458 serverTest("should require authentication", async () => {
1459 const response = await fetch(`${BASE_URL}/api/passkeys`);
1460
1461 expect(response.status).toBe(401);
1462 });
1463 });
1464
1465 describe("POST /api/passkeys/register/options", () => {
1466 serverTest(
1467 "should return registration options for authenticated user",
1468 async () => {
1469 const response = await authRequest(
1470 `${BASE_URL}/api/passkeys/register/options`,
1471 sessionCookie,
1472 {
1473 method: "POST",
1474 },
1475 );
1476
1477 expect(response.status).toBe(200);
1478 const data = await response.json();
1479 expect(data).toHaveProperty("challenge");
1480 expect(data).toHaveProperty("rp");
1481 expect(data).toHaveProperty("user");
1482 },
1483 );
1484
1485 serverTest("should require authentication", async () => {
1486 const response = await fetch(
1487 `${BASE_URL}/api/passkeys/register/options`,
1488 {
1489 method: "POST",
1490 },
1491 );
1492
1493 expect(response.status).toBe(401);
1494 });
1495 });
1496
1497 describe("POST /api/passkeys/authenticate/options", () => {
1498 serverTest("should return authentication options for email", async () => {
1499 const response = await fetch(
1500 `${BASE_URL}/api/passkeys/authenticate/options`,
1501 {
1502 method: "POST",
1503 headers: { "Content-Type": "application/json" },
1504 body: JSON.stringify({ email: TEST_USER.email }),
1505 },
1506 );
1507
1508 expect(response.status).toBe(200);
1509 const data = await response.json();
1510 expect(data).toHaveProperty("challenge");
1511 });
1512
1513 serverTest("should handle non-existent email", async () => {
1514 const response = await fetch(
1515 `${BASE_URL}/api/passkeys/authenticate/options`,
1516 {
1517 method: "POST",
1518 headers: { "Content-Type": "application/json" },
1519 body: JSON.stringify({ email: "nonexistent@example.com" }),
1520 },
1521 );
1522
1523 // Should still return options for privacy (don't leak user existence)
1524 expect([200, 404]).toContain(response.status);
1525 });
1526 });
1527});