🪻 distributed transcription service thistle.dunkirk.sh
at main 31 kB view raw
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});