🪻 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});