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