🪻 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 !== 200) {
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 !== 200) {
279 const error = await response.json();
280 console.error("Registration failed:", response.status, error);
281 }
282
283 expect(response.status).toBe(200);
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(400);
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(200);
435 const data = await response.json();
436 expect(data.success).toBe(true);
437
438 // Verify user is deleted
439 const verifyResponse = await authRequest(
440 `${BASE_URL}/api/auth/me`,
441 sessionCookie,
442 );
443 expect(verifyResponse.status).toBe(401);
444 });
445
446 test("should require authentication", async () => {
447 const response = await fetch(`${BASE_URL}/api/user`, {
448 method: "DELETE",
449 });
450
451 expect(response.status).toBe(401);
452 });
453 });
454
455 describe("PUT /api/user/email", () => {
456 test("should update user email", async () => {
457 // Register and login
458 const sessionCookie = await registerAndLogin(TEST_USER);
459
460 // Update email - this creates a token but doesn't change email yet
461 const newEmail = "newemail@example.com";
462 const response = await authRequest(
463 `${BASE_URL}/api/user/email`,
464 sessionCookie,
465 {
466 method: "PUT",
467 headers: { "Content-Type": "application/json" },
468 body: JSON.stringify({ email: newEmail }),
469 },
470 );
471
472 expect(response.status).toBe(200);
473 const data = await response.json();
474 expect(data.success).toBe(true);
475
476 // Manually complete the email change in the database (simulating verification)
477 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
478 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 };
479 db.run("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", [tokenData.new_email, tokenData.user_id]);
480 db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [tokenData.user_id]);
481 db.close();
482
483 // Verify email updated
484 const meResponse = await authRequest(
485 `${BASE_URL}/api/auth/me`,
486 sessionCookie,
487 );
488 const meData = await meResponse.json();
489 expect(meData.email).toBe(newEmail);
490 });
491
492 test("should reject duplicate email", async () => {
493 // Register two users
494 await registerAndLogin(TEST_USER);
495 const user2Cookie = await registerAndLogin(TEST_USER_2);
496
497 // Try to update user2's email to user1's email
498 const response = await authRequest(
499 `${BASE_URL}/api/user/email`,
500 user2Cookie,
501 {
502 method: "PUT",
503 headers: { "Content-Type": "application/json" },
504 body: JSON.stringify({ email: TEST_USER.email }),
505 },
506 );
507
508 expect(response.status).toBe(400);
509 const data = await response.json();
510 expect(data.error).toBe("Email already in use");
511 });
512 });
513
514 describe("PUT /api/user/password", () => {
515 test("should update user password", async () => {
516 // Register and login
517 const sessionCookie = await registerAndLogin(TEST_USER);
518
519 // Update password
520 const newPassword = await clientHashPassword(
521 TEST_USER.email,
522 "NewPassword123!",
523 );
524 const response = await authRequest(
525 `${BASE_URL}/api/user/password`,
526 sessionCookie,
527 {
528 method: "PUT",
529 headers: { "Content-Type": "application/json" },
530 body: JSON.stringify({ password: newPassword }),
531 },
532 );
533
534 expect(response.status).toBe(200);
535 const data = await response.json();
536 expect(data.success).toBe(true);
537
538 // Verify can login with new password
539 const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
540 method: "POST",
541 headers: { "Content-Type": "application/json" },
542 body: JSON.stringify({
543 email: TEST_USER.email,
544 password: newPassword,
545 }),
546 });
547 expect(loginResponse.status).toBe(200);
548 });
549
550 test("should reject invalid password format", async () => {
551 // Register and login
552 const sessionCookie = await registerAndLogin(TEST_USER);
553
554 // Try to update with invalid format
555 const response = await authRequest(
556 `${BASE_URL}/api/user/password`,
557 sessionCookie,
558 {
559 method: "PUT",
560 headers: { "Content-Type": "application/json" },
561 body: JSON.stringify({ password: "short" }),
562 },
563 );
564
565 expect(response.status).toBe(400);
566 const data = await response.json();
567 expect(data.error).toBe("Invalid password format");
568 });
569 });
570
571 describe("PUT /api/user/name", () => {
572 test("should update user name", async () => {
573 // Register and login
574 const sessionCookie = await registerAndLogin(TEST_USER);
575
576 // Update name
577 const newName = "Updated Name";
578 const response = await authRequest(
579 `${BASE_URL}/api/user/name`,
580 sessionCookie,
581 {
582 method: "PUT",
583 headers: { "Content-Type": "application/json" },
584 body: JSON.stringify({ name: newName }),
585 },
586 );
587
588 expect(response.status).toBe(200);
589 const data = await response.json();
590 expect(data.success).toBe(true);
591
592 // Verify name updated
593 const meResponse = await authRequest(
594 `${BASE_URL}/api/auth/me`,
595 sessionCookie,
596 );
597 const meData = await meResponse.json();
598 expect(meData.name).toBe(newName);
599 });
600
601 test("should reject missing name", async () => {
602 // Register and login
603 const sessionCookie = await registerAndLogin(TEST_USER);
604
605 const response = await authRequest(
606 `${BASE_URL}/api/user/name`,
607 sessionCookie,
608 {
609 method: "PUT",
610 headers: { "Content-Type": "application/json" },
611 body: JSON.stringify({}),
612 },
613 );
614
615 expect(response.status).toBe(400);
616 });
617 });
618
619 describe("PUT /api/user/avatar", () => {
620 test("should update user avatar", async () => {
621 // Register and login
622 const sessionCookie = await registerAndLogin(TEST_USER);
623
624 // Update avatar
625 const newAvatar = "👨💻";
626 const response = await authRequest(
627 `${BASE_URL}/api/user/avatar`,
628 sessionCookie,
629 {
630 method: "PUT",
631 headers: { "Content-Type": "application/json" },
632 body: JSON.stringify({ avatar: newAvatar }),
633 },
634 );
635
636 expect(response.status).toBe(200);
637 const data = await response.json();
638 expect(data.success).toBe(true);
639
640 // Verify avatar updated
641 const meResponse = await authRequest(
642 `${BASE_URL}/api/auth/me`,
643 sessionCookie,
644 );
645 const meData = await meResponse.json();
646 expect(meData.avatar).toBe(newAvatar);
647 });
648 });
649});
650
651describe("API Endpoints - Health", () => {
652 describe("GET /api/health", () => {
653 test(
654 "should return service health status with details",
655 async () => {
656 const response = await fetch(`${BASE_URL}/api/health`);
657
658 expect(response.status).toBe(200);
659 const data = await response.json();
660 expect(data).toHaveProperty("status");
661 expect(data).toHaveProperty("timestamp");
662 expect(data).toHaveProperty("services");
663 expect(data.services).toHaveProperty("database");
664 expect(data.services).toHaveProperty("whisper");
665 expect(data.services).toHaveProperty("storage");
666 },
667 );
668 });
669});
670
671describe("API Endpoints - Transcriptions", () => {
672 describe("GET /api/transcriptions", () => {
673 test("should return user transcriptions", async () => {
674 // Register and login
675 const sessionCookie = await registerAndLogin(TEST_USER);
676
677 // Add subscription
678 addSubscription(TEST_USER.email);
679
680 // Get transcriptions
681 const response = await authRequest(
682 `${BASE_URL}/api/transcriptions`,
683 sessionCookie,
684 );
685
686 expect(response.status).toBe(200);
687 const data = await response.json();
688 expect(data.jobs).toBeDefined();
689 expect(Array.isArray(data.jobs)).toBe(true);
690 });
691
692 test("should require authentication", async () => {
693 const response = await fetch(`${BASE_URL}/api/transcriptions`);
694
695 expect(response.status).toBe(401);
696 });
697 });
698
699 describe("POST /api/transcriptions", () => {
700 test("should upload audio file and start transcription", async () => {
701 // Register and login
702 const sessionCookie = await registerAndLogin(TEST_USER);
703
704 // Add subscription
705 addSubscription(TEST_USER.email);
706
707 // Create a test audio file
708 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
709 const formData = new FormData();
710 formData.append("audio", audioBlob, "test.mp3");
711 formData.append("class_name", "Test Class");
712
713 // Upload
714 const response = await authRequest(
715 `${BASE_URL}/api/transcriptions`,
716 sessionCookie,
717 {
718 method: "POST",
719 body: formData,
720 },
721 );
722
723 expect(response.status).toBe(200);
724 const data = await response.json();
725 expect(data.id).toBeDefined();
726 expect(data.message).toContain("Upload successful");
727 });
728
729 test("should reject non-audio files", async () => {
730 // Register and login
731 const sessionCookie = await registerAndLogin(TEST_USER);
732
733 // Add subscription
734 addSubscription(TEST_USER.email);
735
736 // Try to upload non-audio file
737 const textBlob = new Blob(["text file"], { type: "text/plain" });
738 const formData = new FormData();
739 formData.append("audio", textBlob, "test.txt");
740
741 const response = await authRequest(
742 `${BASE_URL}/api/transcriptions`,
743 sessionCookie,
744 {
745 method: "POST",
746 body: formData,
747 },
748 );
749
750 expect(response.status).toBe(400);
751 });
752
753 test("should reject files exceeding size limit", async () => {
754 // Register and login
755 const sessionCookie = await registerAndLogin(TEST_USER);
756
757 // Add subscription
758 addSubscription(TEST_USER.email);
759
760 // Create a file larger than 100MB (the actual limit)
761 const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
762 type: "audio/mp3",
763 });
764 const formData = new FormData();
765 formData.append("audio", largeBlob, "large.mp3");
766
767 const response = await authRequest(
768 `${BASE_URL}/api/transcriptions`,
769 sessionCookie,
770 {
771 method: "POST",
772 body: formData,
773 },
774 );
775
776 expect(response.status).toBe(400);
777 const data = await response.json();
778 expect(data.error).toContain("File size must be less than");
779 });
780
781 test("should require authentication", async () => {
782 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
783 const formData = new FormData();
784 formData.append("audio", audioBlob, "test.mp3");
785
786 const response = await fetch(`${BASE_URL}/api/transcriptions`, {
787 method: "POST",
788 body: formData,
789 });
790
791 expect(response.status).toBe(401);
792 });
793 });
794});
795
796describe("API Endpoints - Admin", () => {
797 let adminCookie: string;
798 let userCookie: string;
799 let userId: number;
800
801 beforeEach(async () => {
802 // Create admin user
803 adminCookie = await registerAndLogin(TEST_ADMIN);
804
805 // Manually set admin role in database
806 const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
807 db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
808 TEST_ADMIN.email,
809 ]);
810
811 // Create regular user
812 userCookie = await registerAndLogin(TEST_USER);
813
814 // Get user ID
815 const userIdResult = db
816 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
817 .get(TEST_USER.email);
818 userId = userIdResult?.id;
819
820 db.close();
821 });
822
823 describe("GET /api/admin/users", () => {
824 test("should return all users for admin", async () => {
825 const response = await authRequest(
826 `${BASE_URL}/api/admin/users`,
827 adminCookie,
828 );
829
830 expect(response.status).toBe(200);
831 const data = await response.json();
832 expect(Array.isArray(data)).toBe(true);
833 expect(data.length).toBeGreaterThan(0);
834 });
835
836 test("should reject non-admin users", async () => {
837 const response = await authRequest(
838 `${BASE_URL}/api/admin/users`,
839 userCookie,
840 );
841
842 expect(response.status).toBe(403);
843 });
844
845 test("should require authentication", async () => {
846 const response = await fetch(`${BASE_URL}/api/admin/users`);
847
848 expect(response.status).toBe(401);
849 });
850 });
851
852 describe("GET /api/admin/transcriptions", () => {
853 test("should return all transcriptions for admin", async () => {
854 const response = await authRequest(
855 `${BASE_URL}/api/admin/transcriptions`,
856 adminCookie,
857 );
858
859 expect(response.status).toBe(200);
860 const data = await response.json();
861 expect(Array.isArray(data)).toBe(true);
862 });
863
864 test("should reject non-admin users", async () => {
865 const response = await authRequest(
866 `${BASE_URL}/api/admin/transcriptions`,
867 userCookie,
868 );
869
870 expect(response.status).toBe(403);
871 });
872 });
873
874 describe("DELETE /api/admin/users/:id", () => {
875 test("should delete user as admin", async () => {
876 const response = await authRequest(
877 `${BASE_URL}/api/admin/users/${userId}`,
878 adminCookie,
879 {
880 method: "DELETE",
881 },
882 );
883
884 expect(response.status).toBe(200);
885 const data = await response.json();
886 expect(data.success).toBe(true);
887
888 // Verify user is deleted
889 const verifyResponse = await authRequest(
890 `${BASE_URL}/api/auth/me`,
891 userCookie,
892 );
893 expect(verifyResponse.status).toBe(401);
894 });
895
896 test("should reject non-admin users", async () => {
897 const response = await authRequest(
898 `${BASE_URL}/api/admin/users/${userId}`,
899 userCookie,
900 {
901 method: "DELETE",
902 },
903 );
904
905 expect(response.status).toBe(403);
906 });
907 });
908
909 describe("PUT /api/admin/users/:id/role", () => {
910 test("should update user role as admin", async () => {
911 const response = await authRequest(
912 `${BASE_URL}/api/admin/users/${userId}/role`,
913 adminCookie,
914 {
915 method: "PUT",
916 headers: { "Content-Type": "application/json" },
917 body: JSON.stringify({ role: "admin" }),
918 },
919 );
920
921 expect(response.status).toBe(200);
922 const data = await response.json();
923 expect(data.success).toBe(true);
924
925 // Verify role updated
926 const meResponse = await authRequest(
927 `${BASE_URL}/api/auth/me`,
928 userCookie,
929 );
930 const meData = await meResponse.json();
931 expect(meData.role).toBe("admin");
932 });
933
934 test("should reject invalid roles", async () => {
935 const response = await authRequest(
936 `${BASE_URL}/api/admin/users/${userId}/role`,
937 adminCookie,
938 {
939 method: "PUT",
940 headers: { "Content-Type": "application/json" },
941 body: JSON.stringify({ role: "superadmin" }),
942 },
943 );
944
945 expect(response.status).toBe(400);
946 });
947 });
948
949 describe("GET /api/admin/users/:id/details", () => {
950 test("should return user details for admin", async () => {
951 const response = await authRequest(
952 `${BASE_URL}/api/admin/users/${userId}/details`,
953 adminCookie,
954 );
955
956 expect(response.status).toBe(200);
957 const data = await response.json();
958 expect(data.id).toBe(userId);
959 expect(data.email).toBe(TEST_USER.email);
960 expect(data).toHaveProperty("passkeys");
961 expect(data).toHaveProperty("sessions");
962 });
963
964 test("should reject non-admin users", async () => {
965 const response = await authRequest(
966 `${BASE_URL}/api/admin/users/${userId}/details`,
967 userCookie,
968 );
969
970 expect(response.status).toBe(403);
971 });
972 });
973
974 describe("PUT /api/admin/users/:id/name", () => {
975 test("should update user name as admin", async () => {
976 const newName = "Admin Updated Name";
977 const response = await authRequest(
978 `${BASE_URL}/api/admin/users/${userId}/name`,
979 adminCookie,
980 {
981 method: "PUT",
982 headers: { "Content-Type": "application/json" },
983 body: JSON.stringify({ name: newName }),
984 },
985 );
986
987 expect(response.status).toBe(200);
988 const data = await response.json();
989 expect(data.success).toBe(true);
990 });
991
992 test("should reject empty names", async () => {
993 const response = await authRequest(
994 `${BASE_URL}/api/admin/users/${userId}/name`,
995 adminCookie,
996 {
997 method: "PUT",
998 headers: { "Content-Type": "application/json" },
999 body: JSON.stringify({ name: "" }),
1000 },
1001 );
1002
1003 expect(response.status).toBe(400);
1004 });
1005 });
1006
1007 describe("PUT /api/admin/users/:id/email", () => {
1008 test("should update user email as admin", async () => {
1009 const newEmail = "newemail@admin.com";
1010 const response = await authRequest(
1011 `${BASE_URL}/api/admin/users/${userId}/email`,
1012 adminCookie,
1013 {
1014 method: "PUT",
1015 headers: { "Content-Type": "application/json" },
1016 body: JSON.stringify({ email: newEmail }),
1017 },
1018 );
1019
1020 expect(response.status).toBe(200);
1021 const data = await response.json();
1022 expect(data.success).toBe(true);
1023 });
1024
1025 test("should reject duplicate emails", async () => {
1026 const response = await authRequest(
1027 `${BASE_URL}/api/admin/users/${userId}/email`,
1028 adminCookie,
1029 {
1030 method: "PUT",
1031 headers: { "Content-Type": "application/json" },
1032 body: JSON.stringify({ email: TEST_ADMIN.email }),
1033 },
1034 );
1035
1036 expect(response.status).toBe(400);
1037 const data = await response.json();
1038 expect(data.error).toBe("Email already in use");
1039 });
1040 });
1041
1042 describe("GET /api/admin/users/:id/sessions", () => {
1043 test("should return user sessions as admin", async () => {
1044 const response = await authRequest(
1045 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1046 adminCookie,
1047 );
1048
1049 expect(response.status).toBe(200);
1050 const data = await response.json();
1051 expect(Array.isArray(data)).toBe(true);
1052 });
1053 });
1054
1055 describe("DELETE /api/admin/users/:id/sessions", () => {
1056 test("should delete all user sessions as admin", async () => {
1057 const response = await authRequest(
1058 `${BASE_URL}/api/admin/users/${userId}/sessions`,
1059 adminCookie,
1060 {
1061 method: "DELETE",
1062 },
1063 );
1064
1065 expect(response.status).toBe(200);
1066 const data = await response.json();
1067 expect(data.success).toBe(true);
1068
1069 // Verify sessions are deleted
1070 const verifyResponse = await authRequest(
1071 `${BASE_URL}/api/auth/me`,
1072 userCookie,
1073 );
1074 expect(verifyResponse.status).toBe(401);
1075 });
1076 });
1077});
1078
1079describe("API Endpoints - Passkeys", () => {
1080 let sessionCookie: string;
1081
1082 beforeEach(async () => {
1083 // Register and login
1084 sessionCookie = await registerAndLogin(TEST_USER);
1085 });
1086
1087 describe("GET /api/passkeys", () => {
1088 test("should return user passkeys", async () => {
1089 const response = await authRequest(
1090 `${BASE_URL}/api/passkeys`,
1091 sessionCookie,
1092 );
1093
1094 expect(response.status).toBe(200);
1095 const data = await response.json();
1096 expect(data.passkeys).toBeDefined();
1097 expect(Array.isArray(data.passkeys)).toBe(true);
1098 });
1099
1100 test("should require authentication", async () => {
1101 const response = await fetch(`${BASE_URL}/api/passkeys`);
1102
1103 expect(response.status).toBe(401);
1104 });
1105 });
1106
1107 describe("POST /api/passkeys/register/options", () => {
1108 test(
1109 "should return registration options for authenticated user",
1110 async () => {
1111 const response = await authRequest(
1112 `${BASE_URL}/api/passkeys/register/options`,
1113 sessionCookie,
1114 {
1115 method: "POST",
1116 },
1117 );
1118
1119 expect(response.status).toBe(200);
1120 const data = await response.json();
1121 expect(data).toHaveProperty("challenge");
1122 expect(data).toHaveProperty("rp");
1123 expect(data).toHaveProperty("user");
1124 },
1125 );
1126
1127 test("should require authentication", async () => {
1128 const response = await fetch(
1129 `${BASE_URL}/api/passkeys/register/options`,
1130 {
1131 method: "POST",
1132 },
1133 );
1134
1135 expect(response.status).toBe(401);
1136 });
1137 });
1138
1139 describe("POST /api/passkeys/authenticate/options", () => {
1140 test("should return authentication options for email", async () => {
1141 const response = await fetch(
1142 `${BASE_URL}/api/passkeys/authenticate/options`,
1143 {
1144 method: "POST",
1145 headers: { "Content-Type": "application/json" },
1146 body: JSON.stringify({ email: TEST_USER.email }),
1147 },
1148 );
1149
1150 expect(response.status).toBe(200);
1151 const data = await response.json();
1152 expect(data).toHaveProperty("challenge");
1153 });
1154
1155 test("should handle non-existent email", async () => {
1156 const response = await fetch(
1157 `${BASE_URL}/api/passkeys/authenticate/options`,
1158 {
1159 method: "POST",
1160 headers: { "Content-Type": "application/json" },
1161 body: JSON.stringify({ email: "nonexistent@example.com" }),
1162 },
1163 );
1164
1165 // Should still return options for privacy (don't leak user existence)
1166 expect([200, 404]).toContain(response.status);
1167 });
1168 });
1169});