🪻 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// Clear database between each test
124beforeEach(async () => {
125 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
126
127 // Delete all data from tables (preserve schema)
128 db.run("DELETE FROM rate_limit_attempts");
129 db.run("DELETE FROM email_change_tokens");
130 db.run("DELETE FROM password_reset_tokens");
131 db.run("DELETE FROM email_verification_tokens");
132 db.run("DELETE FROM passkeys");
133 db.run("DELETE FROM sessions");
134 db.run("DELETE FROM subscriptions");
135 db.run("DELETE FROM transcriptions");
136 db.run("DELETE FROM class_members");
137 db.run("DELETE FROM meeting_times");
138 db.run("DELETE FROM classes");
139 db.run("DELETE FROM class_waitlist");
140 db.run("DELETE FROM users WHERE id != 0"); // Keep ghost user
141
142 db.close();
143});
144
145// Test user credentials
146const TEST_USER = {
147 email: "test@example.com",
148 password: "TestPassword123!",
149 name: "Test User",
150};
151
152const TEST_ADMIN = {
153 email: "admin@example.com",
154 password: "AdminPassword123!",
155 name: "Admin User",
156};
157
158const TEST_USER_2 = {
159 email: "test2@example.com",
160 password: "TestPassword456!",
161 name: "Test User 2",
162};
163
164// Helper to hash passwords like the client would
165async function clientHashPassword(
166 email: string,
167 password: string,
168): Promise<string> {
169 return await hashPasswordClient(password, email);
170}
171
172// Helper to extract session cookie
173function extractSessionCookie(response: Response): string {
174 const setCookie = response.headers.get("set-cookie");
175 if (!setCookie) throw new Error("No set-cookie header found");
176 const match = setCookie.match(/session=([^;]+)/);
177 if (!match) throw new Error("No session cookie found in set-cookie header");
178 return match[1];
179}
180
181// Helper to make authenticated requests
182function authRequest(
183 url: string,
184 sessionCookie: string,
185 options: RequestInit = {},
186): Promise<Response> {
187 return fetch(url, {
188 ...options,
189 headers: {
190 ...options.headers,
191 Cookie: `session=${sessionCookie}`,
192 },
193 });
194}
195
196// Helper to register a user, verify email, and get session via login
197async function registerAndLogin(user: { email: string; password: string; name?: string }): Promise<string> {
198 const hashedPassword = await clientHashPassword(user.email, user.password);
199
200 // Register the user
201 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
202 method: "POST",
203 headers: { "Content-Type": "application/json" },
204 body: JSON.stringify({
205 email: user.email,
206 password: hashedPassword,
207 name: user.name || "Test User",
208 }),
209 });
210
211 if (registerResponse.status !== 201) {
212 const error = await registerResponse.json();
213 throw new Error(`Registration failed: ${JSON.stringify(error)}`);
214 }
215
216 const registerData = await registerResponse.json();
217 const userId = registerData.user.id;
218
219 // Mark email as verified directly in the database (test mode)
220 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
221 db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]);
222 db.close();
223
224 // Now login to get a session
225 const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
226 method: "POST",
227 headers: { "Content-Type": "application/json" },
228 body: JSON.stringify({
229 email: user.email,
230 password: hashedPassword,
231 }),
232 });
233
234 if (loginResponse.status !== 200) {
235 const error = await loginResponse.json();
236 throw new Error(`Login failed: ${JSON.stringify(error)}`);
237 }
238
239 return extractSessionCookie(loginResponse);
240}
241
242// Helper to add active subscription to a user
243function addSubscription(userEmail: string): void {
244 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
245 const user = db.query("SELECT id FROM users WHERE email = ?").get(userEmail) as { id: number };
246 if (!user) {
247 db.close();
248 throw new Error(`User ${userEmail} not found`);
249 }
250
251 db.run(
252 "INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
253 [`test-sub-${user.id}`, user.id, `test-customer-${user.id}`, "active"]
254 );
255 db.close();
256}
257
258// All tests run against a fresh database, no cleanup needed
259
260describe("API Endpoints - Authentication", () => {
261 describe("POST /api/auth/register", () => {
262 test("should register a new user successfully", async () => {
263 const hashedPassword = await clientHashPassword(
264 TEST_USER.email,
265 TEST_USER.password,
266 );
267
268 const response = await fetch(`${BASE_URL}/api/auth/register`, {
269 method: "POST",
270 headers: { "Content-Type": "application/json" },
271 body: JSON.stringify({
272 email: TEST_USER.email,
273 password: hashedPassword,
274 name: TEST_USER.name,
275 }),
276 });
277
278 if (response.status !== 201) {
279 const error = await response.json();
280 console.error("Registration failed:", response.status, error);
281 }
282
283 expect(response.status).toBe(201);
284
285 const data = await response.json();
286 expect(data.user).toBeDefined();
287 expect(data.user.email).toBe(TEST_USER.email);
288 expect(data.email_verification_required).toBe(true);
289 });
290
291 test("should reject registration with missing email", async () => {
292 const response = await fetch(`${BASE_URL}/api/auth/register`, {
293 method: "POST",
294 headers: { "Content-Type": "application/json" },
295 body: JSON.stringify({
296 password: "hashedpassword123456",
297 }),
298 });
299
300 expect(response.status).toBe(400);
301 const data = await response.json();
302 expect(data.error).toBe("Email and password required");
303 });
304
305 test(
306 "should reject registration with invalid password format",
307 async () => {
308 const response = await fetch(`${BASE_URL}/api/auth/register`, {
309 method: "POST",
310 headers: { "Content-Type": "application/json" },
311 body: JSON.stringify({
312 email: TEST_USER.email,
313 password: "short",
314 }),
315 });
316
317 expect(response.status).toBe(400);
318 const data = await response.json();
319 expect(data.error).toBe("Invalid password format");
320 },
321 );
322
323 test("should reject duplicate email registration", async () => {
324 const hashedPassword = await clientHashPassword(
325 TEST_USER.email,
326 TEST_USER.password,
327 );
328
329 // First registration
330 await fetch(`${BASE_URL}/api/auth/register`, {
331 method: "POST",
332 headers: { "Content-Type": "application/json" },
333 body: JSON.stringify({
334 email: TEST_USER.email,
335 password: hashedPassword,
336 name: TEST_USER.name,
337 }),
338 });
339
340 // Duplicate registration
341 const response = await fetch(`${BASE_URL}/api/auth/register`, {
342 method: "POST",
343 headers: { "Content-Type": "application/json" },
344 body: JSON.stringify({
345 email: TEST_USER.email,
346 password: hashedPassword,
347 name: TEST_USER.name,
348 }),
349 });
350
351 expect(response.status).toBe(409);
352 const data = await response.json();
353 expect(data.error).toBe("Email already registered");
354 });
355
356 test("should enforce rate limiting on registration", async () => {
357 const hashedPassword = await clientHashPassword(
358 "ratelimit@example.com",
359 "password",
360 );
361
362 // First registration succeeds
363 await fetch(`${BASE_URL}/api/auth/register`, {
364 method: "POST",
365 headers: { "Content-Type": "application/json" },
366 body: JSON.stringify({
367 email: "ratelimit@example.com",
368 password: hashedPassword,
369 }),
370 });
371
372 // Try to register same email 10 more times (will fail with 400 but count toward rate limit)
373 // Rate limit is 5 per 30 min from same IP
374 let rateLimitHit = false;
375 for (let i = 0; i < 10; i++) {
376 const response = await fetch(`${BASE_URL}/api/auth/register`, {
377 method: "POST",
378 headers: { "Content-Type": "application/json" },
379 body: JSON.stringify({
380 email: "ratelimit@example.com",
381 password: hashedPassword,
382 }),
383 });
384
385 if (response.status === 429) {
386 rateLimitHit = true;
387 break;
388 }
389 }
390
391 // Verify that rate limiting was triggered
392 expect(rateLimitHit).toBe(true);
393 });
394 });
395
396 describe("POST /api/auth/login", () => {
397 test("should login successfully with valid credentials", async () => {
398 // Register and login
399 const sessionCookie = await registerAndLogin(TEST_USER);
400
401 // Try to delete own current session
402 const response = await authRequest(
403 `${BASE_URL}/api/sessions`,
404 sessionCookie,
405 {
406 method: "DELETE",
407 headers: { "Content-Type": "application/json" },
408 body: JSON.stringify({ sessionId: sessionCookie }),
409 },
410 );
411
412 expect(response.status).toBe(400);
413 const data = await response.json();
414 expect(data.error).toContain("Cannot kill current session");
415 });
416 });
417});
418
419describe("API Endpoints - User Management", () => {
420 describe("DELETE /api/user", () => {
421 test("should delete user account", async () => {
422 // Register and login
423 const sessionCookie = await registerAndLogin(TEST_USER);
424
425 // Delete account
426 const response = await authRequest(
427 `${BASE_URL}/api/user`,
428 sessionCookie,
429 {
430 method: "DELETE",
431 },
432 );
433
434 expect(response.status).toBe(204);
435
436 // Verify user is deleted
437 const verifyResponse = await authRequest(
438 `${BASE_URL}/api/auth/me`,
439 sessionCookie,
440 );
441 expect(verifyResponse.status).toBe(401);
442 });
443
444 test("should require authentication", async () => {
445 const response = await fetch(`${BASE_URL}/api/user`, {
446 method: "DELETE",
447 });
448
449 expect(response.status).toBe(401);
450 });
451 });
452
453 describe("PUT /api/user/email", () => {
454 test("should update user email", async () => {
455 // Register and login
456 const sessionCookie = await registerAndLogin(TEST_USER);
457
458 // Update email - this creates a token but doesn't change email yet
459 const newEmail = "newemail@example.com";
460 const response = await authRequest(
461 `${BASE_URL}/api/user/email`,
462 sessionCookie,
463 {
464 method: "PUT",
465 headers: { "Content-Type": "application/json" },
466 body: JSON.stringify({ email: newEmail }),
467 },
468 );
469
470 expect(response.status).toBe(200);
471 const data = await response.json();
472 expect(data.success).toBe(true);
473
474 // Manually complete the email change in the database (simulating verification)
475 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
476 const tokenData = db.query("SELECT user_id, new_email FROM email_change_tokens ORDER BY created_at DESC LIMIT 1").get() as { user_id: number, new_email: string };
477 db.run("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", [tokenData.new_email, tokenData.user_id]);
478 db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [tokenData.user_id]);
479 db.close();
480
481 // Verify email updated
482 const meResponse = await authRequest(
483 `${BASE_URL}/api/auth/me`,
484 sessionCookie,
485 );
486 const meData = await meResponse.json();
487 expect(meData.email).toBe(newEmail);
488 });
489
490 test("should reject duplicate email", async () => {
491 // Register two users
492 await registerAndLogin(TEST_USER);
493 const user2Cookie = await registerAndLogin(TEST_USER_2);
494
495 // Try to update user2's email to user1's email
496 const response = await authRequest(
497 `${BASE_URL}/api/user/email`,
498 user2Cookie,
499 {
500 method: "PUT",
501 headers: { "Content-Type": "application/json" },
502 body: JSON.stringify({ email: TEST_USER.email }),
503 },
504 );
505
506 expect(response.status).toBe(409);
507 const data = await response.json();
508 expect(data.error).toBe("Email already in use");
509 });
510 });
511
512 describe("PUT /api/user/password", () => {
513 test("should update user password", async () => {
514 // Register and login
515 const sessionCookie = await registerAndLogin(TEST_USER);
516
517 // Update password
518 const newPassword = await clientHashPassword(
519 TEST_USER.email,
520 "NewPassword123!",
521 );
522 const response = await authRequest(
523 `${BASE_URL}/api/user/password`,
524 sessionCookie,
525 {
526 method: "PUT",
527 headers: { "Content-Type": "application/json" },
528 body: JSON.stringify({ password: newPassword }),
529 },
530 );
531
532 expect(response.status).toBe(200);
533 const data = await response.json();
534 expect(data.success).toBe(true);
535
536 // Verify can login with new password
537 const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
538 method: "POST",
539 headers: { "Content-Type": "application/json" },
540 body: JSON.stringify({
541 email: TEST_USER.email,
542 password: newPassword,
543 }),
544 });
545 expect(loginResponse.status).toBe(200);
546 });
547
548 test("should reject invalid password format", async () => {
549 // Register and login
550 const sessionCookie = await registerAndLogin(TEST_USER);
551
552 // Try to update with invalid format
553 const response = await authRequest(
554 `${BASE_URL}/api/user/password`,
555 sessionCookie,
556 {
557 method: "PUT",
558 headers: { "Content-Type": "application/json" },
559 body: JSON.stringify({ password: "short" }),
560 },
561 );
562
563 expect(response.status).toBe(400);
564 const data = await response.json();
565 expect(data.error).toBe("Invalid password format");
566 });
567 });
568
569 describe("PUT /api/user/name", () => {
570 test("should update user name", async () => {
571 // Register and login
572 const sessionCookie = await registerAndLogin(TEST_USER);
573
574 // Update name
575 const newName = "Updated Name";
576 const response = await authRequest(
577 `${BASE_URL}/api/user/name`,
578 sessionCookie,
579 {
580 method: "PUT",
581 headers: { "Content-Type": "application/json" },
582 body: JSON.stringify({ name: newName }),
583 },
584 );
585
586 expect(response.status).toBe(200);
587
588 // Verify name updated
589 const meResponse = await authRequest(
590 `${BASE_URL}/api/auth/me`,
591 sessionCookie,
592 );
593 const meData = await meResponse.json();
594 expect(meData.name).toBe(newName);
595 });
596
597 test("should reject missing name", async () => {
598 // Register and login
599 const sessionCookie = await registerAndLogin(TEST_USER);
600
601 const response = await authRequest(
602 `${BASE_URL}/api/user/name`,
603 sessionCookie,
604 {
605 method: "PUT",
606 headers: { "Content-Type": "application/json" },
607 body: JSON.stringify({}),
608 },
609 );
610
611 expect(response.status).toBe(400);
612 });
613 });
614
615 describe("PUT /api/user/avatar", () => {
616 test("should update user avatar", async () => {
617 // Register and login
618 const sessionCookie = await registerAndLogin(TEST_USER);
619
620 // Update avatar
621 const newAvatar = "👨💻";
622 const response = await authRequest(
623 `${BASE_URL}/api/user/avatar`,
624 sessionCookie,
625 {
626 method: "PUT",
627 headers: { "Content-Type": "application/json" },
628 body: JSON.stringify({ avatar: newAvatar }),
629 },
630 );
631
632 expect(response.status).toBe(200);
633 const data = await response.json();
634 expect(data.success).toBe(true);
635
636 // Verify avatar updated
637 const meResponse = await authRequest(
638 `${BASE_URL}/api/auth/me`,
639 sessionCookie,
640 );
641 const meData = await meResponse.json();
642 expect(meData.avatar).toBe(newAvatar);
643 });
644 });
645});
646
647describe("API Endpoints - Health", () => {
648 describe("GET /api/health", () => {
649 test(
650 "should return service health status with details",
651 async () => {
652 const response = await fetch(`${BASE_URL}/api/health`);
653
654 expect(response.status).toBe(200);
655 const data = await response.json();
656 expect(data).toHaveProperty("status");
657 expect(data).toHaveProperty("timestamp");
658 expect(data).toHaveProperty("services");
659 expect(data.services).toHaveProperty("database");
660 expect(data.services).toHaveProperty("whisper");
661 expect(data.services).toHaveProperty("storage");
662 },
663 );
664 });
665});
666
667describe("API Endpoints - Transcriptions", () => {
668 describe("GET /api/transcriptions", () => {
669 test("should return user transcriptions", async () => {
670 // Register and login
671 const sessionCookie = await registerAndLogin(TEST_USER);
672
673 // Add subscription
674 addSubscription(TEST_USER.email);
675
676 // Get transcriptions
677 const response = await authRequest(
678 `${BASE_URL}/api/transcriptions`,
679 sessionCookie,
680 );
681
682 expect(response.status).toBe(200);
683 const data = await response.json();
684 expect(data.jobs).toBeDefined();
685 expect(Array.isArray(data.jobs)).toBe(true);
686 });
687
688 test("should require authentication", async () => {
689 const response = await fetch(`${BASE_URL}/api/transcriptions`);
690
691 expect(response.status).toBe(401);
692 });
693 });
694
695 describe("POST /api/transcriptions", () => {
696 test("should upload audio file and start transcription", async () => {
697 // Register and login
698 const sessionCookie = await registerAndLogin(TEST_USER);
699
700 // Add subscription
701 addSubscription(TEST_USER.email);
702
703 // Create a test audio file
704 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
705 const formData = new FormData();
706 formData.append("audio", audioBlob, "test.mp3");
707 formData.append("class_name", "Test Class");
708
709 // Upload
710 const response = await authRequest(
711 `${BASE_URL}/api/transcriptions`,
712 sessionCookie,
713 {
714 method: "POST",
715 body: formData,
716 },
717 );
718
719 expect(response.status).toBe(201);
720 const data = await response.json();
721 expect(data.id).toBeDefined();
722 expect(data.message).toContain("Upload successful");
723 });
724
725 test("should reject non-audio files", async () => {
726 // Register and login
727 const sessionCookie = await registerAndLogin(TEST_USER);
728
729 // Add subscription
730 addSubscription(TEST_USER.email);
731
732 // Try to upload non-audio file
733 const textBlob = new Blob(["text file"], { type: "text/plain" });
734 const formData = new FormData();
735 formData.append("audio", textBlob, "test.txt");
736
737 const response = await authRequest(
738 `${BASE_URL}/api/transcriptions`,
739 sessionCookie,
740 {
741 method: "POST",
742 body: formData,
743 },
744 );
745
746 expect(response.status).toBe(400);
747 });
748
749 test("should reject files exceeding size limit", async () => {
750 // Register and login
751 const sessionCookie = await registerAndLogin(TEST_USER);
752
753 // Add subscription
754 addSubscription(TEST_USER.email);
755
756 // Create a file larger than 100MB (the actual limit)
757 const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
758 type: "audio/mp3",
759 });
760 const formData = new FormData();
761 formData.append("audio", largeBlob, "large.mp3");
762
763 const response = await authRequest(
764 `${BASE_URL}/api/transcriptions`,
765 sessionCookie,
766 {
767 method: "POST",
768 body: formData,
769 },
770 );
771
772 expect(response.status).toBe(400);
773 const data = await response.json();
774 expect(data.error).toContain("File size must be less than");
775 });
776
777 test("should require authentication", async () => {
778 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
779 const formData = new FormData();
780 formData.append("audio", audioBlob, "test.mp3");
781
782 const response = await fetch(`${BASE_URL}/api/transcriptions`, {
783 method: "POST",
784 body: formData,
785 });
786
787 expect(response.status).toBe(401);
788 });
789 });
790});
791
792describe("API Endpoints - Admin", () => {
793 let adminCookie: string;
794 let userCookie: string;
795 let userId: number;
796
797 beforeEach(async () => {
798 // Create admin user
799 adminCookie = await registerAndLogin(TEST_ADMIN);
800
801 // Manually set admin role in database
802 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
803 db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
804 TEST_ADMIN.email,
805 ]);
806
807 // Create regular user
808 userCookie = await registerAndLogin(TEST_USER);
809
810 // Get user ID
811 const userIdResult = db
812 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
813 .get(TEST_USER.email);
814 userId = userIdResult?.id;
815
816 db.close();
817 });
818
819 describe("GET /api/admin/users", () => {
820 test("should return all users for admin", async () => {
821 const response = await authRequest(
822 `${BASE_URL}/api/admin/users`,
823 adminCookie,
824 );
825
826 expect(response.status).toBe(200);
827 const data = await response.json();
828 expect(Array.isArray(data)).toBe(true);
829 expect(data.length).toBeGreaterThan(0);
830 });
831
832 test("should reject non-admin users", async () => {
833 const response = await authRequest(
834 `${BASE_URL}/api/admin/users`,
835 userCookie,
836 );
837
838 expect(response.status).toBe(403);
839 });
840
841 test("should require authentication", async () => {
842 const response = await fetch(`${BASE_URL}/api/admin/users`);
843
844 expect(response.status).toBe(401);
845 });
846 });
847
848 describe("GET /api/admin/transcriptions", () => {
849 test("should return all transcriptions for admin", async () => {
850 const response = await authRequest(
851 `${BASE_URL}/api/admin/transcriptions`,
852 adminCookie,
853 );
854
855 expect(response.status).toBe(200);
856 const data = await response.json();
857 expect(Array.isArray(data)).toBe(true);
858 });
859
860 test("should reject non-admin users", async () => {
861 const response = await authRequest(
862 `${BASE_URL}/api/admin/transcriptions`,
863 userCookie,
864 );
865
866 expect(response.status).toBe(403);
867 });
868 });
869
870 describe("DELETE /api/admin/users/:id", () => {
871 test("should delete user as admin", async () => {
872 const response = await authRequest(
873 `${BASE_URL}/api/admin/users/${userId}`,
874 adminCookie,
875 {
876 method: "DELETE",
877 },
878 );
879
880 expect(response.status).toBe(204);
881
882 // Verify user is deleted
883 const verifyResponse = await authRequest(
884 `${BASE_URL}/api/auth/me`,
885 userCookie,
886 );
887 expect(verifyResponse.status).toBe(401);
888 });
889
890 test("should reject non-admin users", async () => {
891 const response = await authRequest(
892 `${BASE_URL}/api/admin/users/${userId}`,
893 userCookie,
894 {
895 method: "DELETE",
896 },
897 );
898
899 expect(response.status).toBe(403);
900 });
901 });
902
903 describe("PUT /api/admin/users/:id/role", () => {
904 test("should update user role as admin", async () => {
905 const response = await authRequest(
906 `${BASE_URL}/api/admin/users/${userId}/role`,
907 adminCookie,
908 {
909 method: "PUT",
910 headers: { "Content-Type": "application/json" },
911 body: JSON.stringify({ role: "admin" }),
912 },
913 );
914
915 expect(response.status).toBe(200);
916
917 // Verify role updated
918 const meResponse = await authRequest(
919 `${BASE_URL}/api/auth/me`,
920 userCookie,
921 );
922 const meData = await meResponse.json();
923 expect(meData.role).toBe("admin");
924 });
925
926 test("should reject invalid roles", async () => {
927 const response = await authRequest(
928 `${BASE_URL}/api/admin/users/${userId}/role`,
929 adminCookie,
930 {
931 method: "PUT",
932 headers: { "Content-Type": "application/json" },
933 body: JSON.stringify({ role: "superadmin" }),
934 },
935 );
936
937 expect(response.status).toBe(400);
938 });
939 });
940
941 describe("GET /api/admin/users/:id/details", () => {
942 test("should return user details for admin", async () => {
943 const response = await authRequest(
944 `${BASE_URL}/api/admin/users/${userId}/details`,
945 adminCookie,
946 );
947
948 expect(response.status).toBe(200);
949 const data = await response.json();
950 expect(data.id).toBe(userId);
951 expect(data.email).toBe(TEST_USER.email);
952 expect(data).toHaveProperty("passkeys");
953 expect(data).toHaveProperty("sessions");
954 });
955
956 test("should reject non-admin users", async () => {
957 const response = await authRequest(
958 `${BASE_URL}/api/admin/users/${userId}/details`,
959 userCookie,
960 );
961
962 expect(response.status).toBe(403);
963 });
964 });
965
966 describe("PUT /api/admin/users/:id/name", () => {
967 test("should update user name as admin", async () => {
968 const newName = "Admin Updated Name";
969 const response = await authRequest(
970 `${BASE_URL}/api/admin/users/${userId}/name`,
971 adminCookie,
972 {
973 method: "PUT",
974 headers: { "Content-Type": "application/json" },
975 body: JSON.stringify({ name: newName }),
976 },
977 );
978
979 expect(response.status).toBe(200);
980 const data = await response.json();
981 expect(data.success).toBe(true);
982 });
983
984 test("should reject empty names", async () => {
985 const response = await authRequest(
986 `${BASE_URL}/api/admin/users/${userId}/name`,
987 adminCookie,
988 {
989 method: "PUT",
990 headers: { "Content-Type": "application/json" },
991 body: JSON.stringify({ name: "" }),
992 },
993 );
994
995 expect(response.status).toBe(400);
996 });
997 });
998
999 describe("PUT /api/admin/users/:id/email", () => {
1000 test("should update user email as admin", async () => {
1001 const newEmail = "newemail@admin.com";
1002 const response = await authRequest(
1003 `${BASE_URL}/api/admin/users/${userId}/email`,
1004 adminCookie,
1005 {
1006 method: "PUT",
1007 headers: { "Content-Type": "application/json" },
1008 body: JSON.stringify({ email: newEmail }),
1009 },
1010 );
1011
1012 expect(response.status).toBe(200);
1013 const data = await response.json();
1014 expect(data.success).toBe(true);
1015 });
1016
1017 test("should reject duplicate emails", async () => {
1018 const response = await authRequest(
1019 `${BASE_URL}/api/admin/users/${userId}/email`,
1020 adminCookie,
1021 {
1022 method: "PUT",
1023 headers: { "Content-Type": "application/json" },
1024 body: JSON.stringify({ email: TEST_ADMIN.email }),
1025 },
1026 );
1027
1028 expect(response.status).toBe(409);
1029 const data = await response.json();
1030 expect(data.error).toBe("Email already in use");
1031 });
1032 });
1033
1034 describe("GET /api/admin/users/:id/sessions", () => {
1035 test("should return user sessions as admin", async () => {
1036 const response = await authRequest(
1037 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1038 adminCookie,
1039 );
1040
1041 expect(response.status).toBe(200);
1042 const data = await response.json();
1043 expect(Array.isArray(data)).toBe(true);
1044 });
1045 });
1046
1047 describe("DELETE /api/admin/users/:id/sessions", () => {
1048 test("should delete all user sessions as admin", async () => {
1049 const response = await authRequest(
1050 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1051 adminCookie,
1052 {
1053 method: "DELETE",
1054 },
1055 );
1056
1057 expect(response.status).toBe(204);
1058
1059 // Verify sessions are deleted
1060 const verifyResponse = await authRequest(
1061 `${BASE_URL}/api/auth/me`,
1062 userCookie,
1063 );
1064 expect(verifyResponse.status).toBe(401);
1065 });
1066 });
1067});
1068
1069describe("API Endpoints - Passkeys", () => {
1070 let sessionCookie: string;
1071
1072 beforeEach(async () => {
1073 // Register and login
1074 sessionCookie = await registerAndLogin(TEST_USER);
1075 });
1076
1077 describe("GET /api/passkeys", () => {
1078 test("should return user passkeys", async () => {
1079 const response = await authRequest(
1080 `${BASE_URL}/api/passkeys`,
1081 sessionCookie,
1082 );
1083
1084 expect(response.status).toBe(200);
1085 const data = await response.json();
1086 expect(data.passkeys).toBeDefined();
1087 expect(Array.isArray(data.passkeys)).toBe(true);
1088 });
1089
1090 test("should require authentication", async () => {
1091 const response = await fetch(`${BASE_URL}/api/passkeys`);
1092
1093 expect(response.status).toBe(401);
1094 });
1095 });
1096
1097 describe("POST /api/passkeys/register/options", () => {
1098 test(
1099 "should return registration options for authenticated user",
1100 async () => {
1101 const response = await authRequest(
1102 `${BASE_URL}/api/passkeys/register/options`,
1103 sessionCookie,
1104 {
1105 method: "POST",
1106 },
1107 );
1108
1109 expect(response.status).toBe(200);
1110 const data = await response.json();
1111 expect(data).toHaveProperty("challenge");
1112 expect(data).toHaveProperty("rp");
1113 expect(data).toHaveProperty("user");
1114 },
1115 );
1116
1117 test("should require authentication", async () => {
1118 const response = await fetch(
1119 `${BASE_URL}/api/passkeys/register/options`,
1120 {
1121 method: "POST",
1122 },
1123 );
1124
1125 expect(response.status).toBe(401);
1126 });
1127 });
1128
1129 describe("POST /api/passkeys/authenticate/options", () => {
1130 test("should return authentication options for email", async () => {
1131 const response = await fetch(
1132 `${BASE_URL}/api/passkeys/authenticate/options`,
1133 {
1134 method: "POST",
1135 headers: { "Content-Type": "application/json" },
1136 body: JSON.stringify({ email: TEST_USER.email }),
1137 },
1138 );
1139
1140 expect(response.status).toBe(200);
1141 const data = await response.json();
1142 expect(data).toHaveProperty("challenge");
1143 });
1144
1145 test("should handle non-existent email", async () => {
1146 const response = await fetch(
1147 `${BASE_URL}/api/passkeys/authenticate/options`,
1148 {
1149 method: "POST",
1150 headers: { "Content-Type": "application/json" },
1151 body: JSON.stringify({ email: "nonexistent@example.com" }),
1152 },
1153 );
1154
1155 // Should still return options for privacy (don't leak user existence)
1156 expect([200, 404]).toContain(response.status);
1157 });
1158 });
1159});