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