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