🪻 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// Test user credentials 124const TEST_USER = { 125 email: "test@example.com", 126 password: "TestPassword123!", 127 name: "Test User", 128}; 129 130const TEST_ADMIN = { 131 email: "admin@example.com", 132 password: "AdminPassword123!", 133 name: "Admin User", 134}; 135 136const TEST_USER_2 = { 137 email: "test2@example.com", 138 password: "TestPassword456!", 139 name: "Test User 2", 140}; 141 142// Helper to hash passwords like the client would 143async function clientHashPassword( 144 email: string, 145 password: string, 146): Promise<string> { 147 return await hashPasswordClient(password, email); 148} 149 150// Helper to extract session cookie 151function extractSessionCookie(response: Response): string { 152 const setCookie = response.headers.get("set-cookie"); 153 if (!setCookie) throw new Error("No set-cookie header found"); 154 const match = setCookie.match(/session=([^;]+)/); 155 if (!match) throw new Error("No session cookie found in set-cookie header"); 156 return match[1]; 157} 158 159// Helper to make authenticated requests 160function authRequest( 161 url: string, 162 sessionCookie: string, 163 options: RequestInit = {}, 164): Promise<Response> { 165 return fetch(url, { 166 ...options, 167 headers: { 168 ...options.headers, 169 Cookie: `session=${sessionCookie}`, 170 }, 171 }); 172} 173 174// All tests run against a fresh database, no cleanup needed 175 176describe("API Endpoints - Authentication", () => { 177 describe("POST /api/auth/register", () => { 178 test("should register a new user successfully", async () => { 179 const hashedPassword = await clientHashPassword( 180 TEST_USER.email, 181 TEST_USER.password, 182 ); 183 184 const response = await fetch(`${BASE_URL}/api/auth/register`, { 185 method: "POST", 186 headers: { "Content-Type": "application/json" }, 187 body: JSON.stringify({ 188 email: TEST_USER.email, 189 password: hashedPassword, 190 name: TEST_USER.name, 191 }), 192 }); 193 194 if (response.status !== 200) { 195 const error = await response.json(); 196 console.error("Registration failed:", response.status, error); 197 } 198 199 expect(response.status).toBe(200); 200 201 // Extract session before consuming response body 202 const sessionCookie = extractSessionCookie(response); 203 204 const data = await response.json(); 205 expect(data.user).toBeDefined(); 206 expect(data.user.email).toBe(TEST_USER.email); 207 expect(sessionCookie).toBeTruthy(); 208 }); 209 210 test("should reject registration with missing email", async () => { 211 const response = await fetch(`${BASE_URL}/api/auth/register`, { 212 method: "POST", 213 headers: { "Content-Type": "application/json" }, 214 body: JSON.stringify({ 215 password: "hashedpassword123456", 216 }), 217 }); 218 219 expect(response.status).toBe(400); 220 const data = await response.json(); 221 expect(data.error).toBe("Email and password required"); 222 }); 223 224 test( 225 "should reject registration with invalid password format", 226 async () => { 227 const response = await fetch(`${BASE_URL}/api/auth/register`, { 228 method: "POST", 229 headers: { "Content-Type": "application/json" }, 230 body: JSON.stringify({ 231 email: TEST_USER.email, 232 password: "short", 233 }), 234 }); 235 236 expect(response.status).toBe(400); 237 const data = await response.json(); 238 expect(data.error).toBe("Invalid password format"); 239 }, 240 ); 241 242 test("should reject duplicate email registration", async () => { 243 const hashedPassword = await clientHashPassword( 244 TEST_USER.email, 245 TEST_USER.password, 246 ); 247 248 // First registration 249 await fetch(`${BASE_URL}/api/auth/register`, { 250 method: "POST", 251 headers: { "Content-Type": "application/json" }, 252 body: JSON.stringify({ 253 email: TEST_USER.email, 254 password: hashedPassword, 255 name: TEST_USER.name, 256 }), 257 }); 258 259 // Duplicate registration 260 const response = await fetch(`${BASE_URL}/api/auth/register`, { 261 method: "POST", 262 headers: { "Content-Type": "application/json" }, 263 body: JSON.stringify({ 264 email: TEST_USER.email, 265 password: hashedPassword, 266 name: TEST_USER.name, 267 }), 268 }); 269 270 expect(response.status).toBe(400); 271 const data = await response.json(); 272 expect(data.error).toBe("Email already registered"); 273 }); 274 275 test("should enforce rate limiting on registration", async () => { 276 const hashedPassword = await clientHashPassword( 277 "test@example.com", 278 "password", 279 ); 280 281 // Make registration attempts until rate limit is hit (limit is 5 per hour) 282 let rateLimitHit = false; 283 for (let i = 0; i < 10; i++) { 284 const response = await fetch(`${BASE_URL}/api/auth/register`, { 285 method: "POST", 286 headers: { "Content-Type": "application/json" }, 287 body: JSON.stringify({ 288 email: `test${i}@example.com`, 289 password: hashedPassword, 290 }), 291 }); 292 293 if (response.status === 429) { 294 rateLimitHit = true; 295 break; 296 } 297 } 298 299 // Verify that rate limiting was triggered 300 expect(rateLimitHit).toBe(true); 301 }); 302 }); 303 304 describe("POST /api/auth/login", () => { 305 test("should login successfully with valid credentials", async () => { 306 // Register user first 307 const hashedPassword = await clientHashPassword( 308 TEST_USER.email, 309 TEST_USER.password, 310 ); 311 await fetch(`${BASE_URL}/api/auth/register`, { 312 method: "POST", 313 headers: { "Content-Type": "application/json" }, 314 body: JSON.stringify({ 315 email: TEST_USER.email, 316 password: hashedPassword, 317 name: TEST_USER.name, 318 }), 319 }); 320 321 // Login 322 const response = await fetch(`${BASE_URL}/api/auth/login`, { 323 method: "POST", 324 headers: { "Content-Type": "application/json" }, 325 body: JSON.stringify({ 326 email: TEST_USER.email, 327 password: hashedPassword, 328 }), 329 }); 330 331 expect(response.status).toBe(200); 332 const data = await response.json(); 333 expect(data.user).toBeDefined(); 334 expect(data.user.email).toBe(TEST_USER.email); 335 expect(extractSessionCookie(response)).toBeTruthy(); 336 }); 337 338 test("should reject login with invalid credentials", async () => { 339 // Register user first 340 const hashedPassword = await clientHashPassword( 341 TEST_USER.email, 342 TEST_USER.password, 343 ); 344 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 }), 351 }); 352 353 // Login with wrong password 354 const wrongPassword = await clientHashPassword( 355 TEST_USER.email, 356 "WrongPassword123!", 357 ); 358 const response = await fetch(`${BASE_URL}/api/auth/login`, { 359 method: "POST", 360 headers: { "Content-Type": "application/json" }, 361 body: JSON.stringify({ 362 email: TEST_USER.email, 363 password: wrongPassword, 364 }), 365 }); 366 367 expect(response.status).toBe(401); 368 const data = await response.json(); 369 expect(data.error).toBe("Invalid email or password"); 370 }); 371 372 test("should reject login with missing fields", async () => { 373 const response = await fetch(`${BASE_URL}/api/auth/login`, { 374 method: "POST", 375 headers: { "Content-Type": "application/json" }, 376 body: JSON.stringify({ 377 email: TEST_USER.email, 378 }), 379 }); 380 381 expect(response.status).toBe(400); 382 const data = await response.json(); 383 expect(data.error).toBe("Email and password required"); 384 }); 385 386 test("should enforce rate limiting on login attempts", async () => { 387 const hashedPassword = await clientHashPassword( 388 TEST_USER.email, 389 TEST_USER.password, 390 ); 391 392 // Make 11 login attempts (limit is 10 per 15 minutes per IP) 393 let rateLimitHit = false; 394 for (let i = 0; i < 11; i++) { 395 const response = await fetch(`${BASE_URL}/api/auth/login`, { 396 method: "POST", 397 headers: { "Content-Type": "application/json" }, 398 body: JSON.stringify({ 399 email: TEST_USER.email, 400 password: hashedPassword, 401 }), 402 }); 403 404 if (response.status === 429) { 405 rateLimitHit = true; 406 break; 407 } 408 } 409 410 // Verify that rate limiting was triggered 411 expect(rateLimitHit).toBe(true); 412 }); 413 }); 414 415 describe("POST /api/auth/logout", () => { 416 test("should logout successfully", async () => { 417 // Register and login 418 const hashedPassword = await clientHashPassword( 419 TEST_USER.email, 420 TEST_USER.password, 421 ); 422 const loginResponse = await fetch(`${BASE_URL}/api/auth/register`, { 423 method: "POST", 424 headers: { "Content-Type": "application/json" }, 425 body: JSON.stringify({ 426 email: TEST_USER.email, 427 password: hashedPassword, 428 }), 429 }); 430 const sessionCookie = extractSessionCookie(loginResponse); 431 432 // Logout 433 const response = await authRequest( 434 `${BASE_URL}/api/auth/logout`, 435 sessionCookie, 436 { 437 method: "POST", 438 }, 439 ); 440 441 expect(response.status).toBe(200); 442 const data = await response.json(); 443 expect(data.success).toBe(true); 444 445 // Verify cookie is cleared 446 const setCookie = response.headers.get("set-cookie"); 447 expect(setCookie).toContain("Max-Age=0"); 448 }); 449 450 test("should logout even without valid session", async () => { 451 const response = await fetch(`${BASE_URL}/api/auth/logout`, { 452 method: "POST", 453 }); 454 455 expect(response.status).toBe(200); 456 const data = await response.json(); 457 expect(data.success).toBe(true); 458 }); 459 }); 460 461 describe("GET /api/auth/me", () => { 462 test( 463 "should return current user info when authenticated", 464 async () => { 465 // Register user 466 const hashedPassword = await clientHashPassword( 467 TEST_USER.email, 468 TEST_USER.password, 469 ); 470 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 471 method: "POST", 472 headers: { "Content-Type": "application/json" }, 473 body: JSON.stringify({ 474 email: TEST_USER.email, 475 password: hashedPassword, 476 name: TEST_USER.name, 477 }), 478 }); 479 const sessionCookie = extractSessionCookie(registerResponse); 480 481 // Get current user 482 const response = await authRequest( 483 `${BASE_URL}/api/auth/me`, 484 sessionCookie, 485 ); 486 487 expect(response.status).toBe(200); 488 const data = await response.json(); 489 expect(data.email).toBe(TEST_USER.email); 490 expect(data.name).toBe(TEST_USER.name); 491 expect(data.role).toBeDefined(); 492 }, 493 ); 494 495 test("should return 401 when not authenticated", async () => { 496 const response = await fetch(`${BASE_URL}/api/auth/me`); 497 498 expect(response.status).toBe(401); 499 const data = await response.json(); 500 expect(data.error).toBe("Not authenticated"); 501 }); 502 503 test("should return 401 with invalid session", async () => { 504 const response = await authRequest( 505 `${BASE_URL}/api/auth/me`, 506 "invalid-session", 507 ); 508 509 expect(response.status).toBe(401); 510 const data = await response.json(); 511 expect(data.error).toBe("Invalid session"); 512 }); 513 }); 514}); 515 516describe("API Endpoints - Session Management", () => { 517 describe("GET /api/sessions", () => { 518 test("should return user sessions", async () => { 519 // Register user 520 const hashedPassword = await clientHashPassword( 521 TEST_USER.email, 522 TEST_USER.password, 523 ); 524 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 525 method: "POST", 526 headers: { "Content-Type": "application/json" }, 527 body: JSON.stringify({ 528 email: TEST_USER.email, 529 password: hashedPassword, 530 }), 531 }); 532 const sessionCookie = extractSessionCookie(registerResponse); 533 534 // Get sessions 535 const response = await authRequest( 536 `${BASE_URL}/api/sessions`, 537 sessionCookie, 538 ); 539 540 expect(response.status).toBe(200); 541 const data = await response.json(); 542 expect(data.sessions).toBeDefined(); 543 expect(data.sessions.length).toBeGreaterThan(0); 544 expect(data.sessions[0]).toHaveProperty("id"); 545 expect(data.sessions[0]).toHaveProperty("ip_address"); 546 expect(data.sessions[0]).toHaveProperty("user_agent"); 547 }); 548 549 test("should require authentication", async () => { 550 const response = await fetch(`${BASE_URL}/api/sessions`); 551 552 expect(response.status).toBe(401); 553 }); 554 }); 555 556 describe("DELETE /api/sessions", () => { 557 test("should delete specific session", async () => { 558 // Register user and create multiple sessions 559 const hashedPassword = await clientHashPassword( 560 TEST_USER.email, 561 TEST_USER.password, 562 ); 563 const session1Response = await fetch(`${BASE_URL}/api/auth/register`, { 564 method: "POST", 565 headers: { "Content-Type": "application/json" }, 566 body: JSON.stringify({ 567 email: TEST_USER.email, 568 password: hashedPassword, 569 }), 570 }); 571 const session1Cookie = extractSessionCookie(session1Response); 572 573 const session2Response = await fetch(`${BASE_URL}/api/auth/login`, { 574 method: "POST", 575 headers: { "Content-Type": "application/json" }, 576 body: JSON.stringify({ 577 email: TEST_USER.email, 578 password: hashedPassword, 579 }), 580 }); 581 const session2Cookie = extractSessionCookie(session2Response); 582 583 // Get sessions list 584 const sessionsResponse = await authRequest( 585 `${BASE_URL}/api/sessions`, 586 session1Cookie, 587 ); 588 const sessionsData = await sessionsResponse.json(); 589 const targetSessionId = sessionsData.sessions.find( 590 (s: { id: string }) => s.id === session2Cookie, 591 )?.id; 592 593 // Delete session 2 594 const response = await authRequest( 595 `${BASE_URL}/api/sessions`, 596 session1Cookie, 597 { 598 method: "DELETE", 599 headers: { "Content-Type": "application/json" }, 600 body: JSON.stringify({ sessionId: targetSessionId }), 601 }, 602 ); 603 604 expect(response.status).toBe(200); 605 const data = await response.json(); 606 expect(data.success).toBe(true); 607 608 // Verify session 2 is deleted 609 const verifyResponse = await authRequest( 610 `${BASE_URL}/api/auth/me`, 611 session2Cookie, 612 ); 613 expect(verifyResponse.status).toBe(401); 614 }); 615 616 test("should not delete another user's session", async () => { 617 // Register two users 618 const hashedPassword1 = await clientHashPassword( 619 TEST_USER.email, 620 TEST_USER.password, 621 ); 622 const user1Response = await fetch(`${BASE_URL}/api/auth/register`, { 623 method: "POST", 624 headers: { "Content-Type": "application/json" }, 625 body: JSON.stringify({ 626 email: TEST_USER.email, 627 password: hashedPassword1, 628 }), 629 }); 630 const user1Cookie = extractSessionCookie(user1Response); 631 632 const hashedPassword2 = await clientHashPassword( 633 TEST_USER_2.email, 634 TEST_USER_2.password, 635 ); 636 const user2Response = await fetch(`${BASE_URL}/api/auth/register`, { 637 method: "POST", 638 headers: { "Content-Type": "application/json" }, 639 body: JSON.stringify({ 640 email: TEST_USER_2.email, 641 password: hashedPassword2, 642 }), 643 }); 644 const user2Cookie = extractSessionCookie(user2Response); 645 646 // Try to delete user2's session using user1's credentials 647 const response = await authRequest( 648 `${BASE_URL}/api/sessions`, 649 user1Cookie, 650 { 651 method: "DELETE", 652 headers: { "Content-Type": "application/json" }, 653 body: JSON.stringify({ sessionId: user2Cookie }), 654 }, 655 ); 656 657 expect(response.status).toBe(404); 658 }); 659 660 test("should not delete current session", async () => { 661 // Register user 662 const hashedPassword = await clientHashPassword( 663 TEST_USER.email, 664 TEST_USER.password, 665 ); 666 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 667 method: "POST", 668 headers: { "Content-Type": "application/json" }, 669 body: JSON.stringify({ 670 email: TEST_USER.email, 671 password: hashedPassword, 672 }), 673 }); 674 const sessionCookie = extractSessionCookie(registerResponse); 675 676 // Try to delete own current session 677 const response = await authRequest( 678 `${BASE_URL}/api/sessions`, 679 sessionCookie, 680 { 681 method: "DELETE", 682 headers: { "Content-Type": "application/json" }, 683 body: JSON.stringify({ sessionId: sessionCookie }), 684 }, 685 ); 686 687 expect(response.status).toBe(400); 688 const data = await response.json(); 689 expect(data.error).toContain("Cannot kill current session"); 690 }); 691 }); 692}); 693 694describe("API Endpoints - User Management", () => { 695 describe("DELETE /api/user", () => { 696 test("should delete user account", async () => { 697 // Register user 698 const hashedPassword = await clientHashPassword( 699 TEST_USER.email, 700 TEST_USER.password, 701 ); 702 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 703 method: "POST", 704 headers: { "Content-Type": "application/json" }, 705 body: JSON.stringify({ 706 email: TEST_USER.email, 707 password: hashedPassword, 708 }), 709 }); 710 const sessionCookie = extractSessionCookie(registerResponse); 711 712 // Delete account 713 const response = await authRequest( 714 `${BASE_URL}/api/user`, 715 sessionCookie, 716 { 717 method: "DELETE", 718 }, 719 ); 720 721 expect(response.status).toBe(200); 722 const data = await response.json(); 723 expect(data.success).toBe(true); 724 725 // Verify user is deleted 726 const verifyResponse = await authRequest( 727 `${BASE_URL}/api/auth/me`, 728 sessionCookie, 729 ); 730 expect(verifyResponse.status).toBe(401); 731 }); 732 733 test("should require authentication", async () => { 734 const response = await fetch(`${BASE_URL}/api/user`, { 735 method: "DELETE", 736 }); 737 738 expect(response.status).toBe(401); 739 }); 740 }); 741 742 describe("PUT /api/user/email", () => { 743 test("should update user email", async () => { 744 // Register user 745 const hashedPassword = await clientHashPassword( 746 TEST_USER.email, 747 TEST_USER.password, 748 ); 749 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 750 method: "POST", 751 headers: { "Content-Type": "application/json" }, 752 body: JSON.stringify({ 753 email: TEST_USER.email, 754 password: hashedPassword, 755 }), 756 }); 757 const sessionCookie = extractSessionCookie(registerResponse); 758 759 // Update email 760 const newEmail = "newemail@example.com"; 761 const response = await authRequest( 762 `${BASE_URL}/api/user/email`, 763 sessionCookie, 764 { 765 method: "PUT", 766 headers: { "Content-Type": "application/json" }, 767 body: JSON.stringify({ email: newEmail }), 768 }, 769 ); 770 771 expect(response.status).toBe(200); 772 const data = await response.json(); 773 expect(data.success).toBe(true); 774 775 // Verify email updated 776 const meResponse = await authRequest( 777 `${BASE_URL}/api/auth/me`, 778 sessionCookie, 779 ); 780 const meData = await meResponse.json(); 781 expect(meData.email).toBe(newEmail); 782 }); 783 784 test("should reject duplicate email", async () => { 785 // Register two users 786 const hashedPassword1 = await clientHashPassword( 787 TEST_USER.email, 788 TEST_USER.password, 789 ); 790 await fetch(`${BASE_URL}/api/auth/register`, { 791 method: "POST", 792 headers: { "Content-Type": "application/json" }, 793 body: JSON.stringify({ 794 email: TEST_USER.email, 795 password: hashedPassword1, 796 }), 797 }); 798 799 const hashedPassword2 = await clientHashPassword( 800 TEST_USER_2.email, 801 TEST_USER_2.password, 802 ); 803 const user2Response = await fetch(`${BASE_URL}/api/auth/register`, { 804 method: "POST", 805 headers: { "Content-Type": "application/json" }, 806 body: JSON.stringify({ 807 email: TEST_USER_2.email, 808 password: hashedPassword2, 809 }), 810 }); 811 const user2Cookie = extractSessionCookie(user2Response); 812 813 // Try to update user2's email to user1's email 814 const response = await authRequest( 815 `${BASE_URL}/api/user/email`, 816 user2Cookie, 817 { 818 method: "PUT", 819 headers: { "Content-Type": "application/json" }, 820 body: JSON.stringify({ email: TEST_USER.email }), 821 }, 822 ); 823 824 expect(response.status).toBe(400); 825 const data = await response.json(); 826 expect(data.error).toBe("Email already in use"); 827 }); 828 }); 829 830 describe("PUT /api/user/password", () => { 831 test("should update user password", async () => { 832 // Register user 833 const hashedPassword = await clientHashPassword( 834 TEST_USER.email, 835 TEST_USER.password, 836 ); 837 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 838 method: "POST", 839 headers: { "Content-Type": "application/json" }, 840 body: JSON.stringify({ 841 email: TEST_USER.email, 842 password: hashedPassword, 843 }), 844 }); 845 const sessionCookie = extractSessionCookie(registerResponse); 846 847 // Update password 848 const newPassword = await clientHashPassword( 849 TEST_USER.email, 850 "NewPassword123!", 851 ); 852 const response = await authRequest( 853 `${BASE_URL}/api/user/password`, 854 sessionCookie, 855 { 856 method: "PUT", 857 headers: { "Content-Type": "application/json" }, 858 body: JSON.stringify({ password: newPassword }), 859 }, 860 ); 861 862 expect(response.status).toBe(200); 863 const data = await response.json(); 864 expect(data.success).toBe(true); 865 866 // Verify can login with new password 867 const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, { 868 method: "POST", 869 headers: { "Content-Type": "application/json" }, 870 body: JSON.stringify({ 871 email: TEST_USER.email, 872 password: newPassword, 873 }), 874 }); 875 expect(loginResponse.status).toBe(200); 876 }); 877 878 test("should reject invalid password format", async () => { 879 // Register user 880 const hashedPassword = await clientHashPassword( 881 TEST_USER.email, 882 TEST_USER.password, 883 ); 884 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 885 method: "POST", 886 headers: { "Content-Type": "application/json" }, 887 body: JSON.stringify({ 888 email: TEST_USER.email, 889 password: hashedPassword, 890 }), 891 }); 892 const sessionCookie = extractSessionCookie(registerResponse); 893 894 // Try to update with invalid format 895 const response = await authRequest( 896 `${BASE_URL}/api/user/password`, 897 sessionCookie, 898 { 899 method: "PUT", 900 headers: { "Content-Type": "application/json" }, 901 body: JSON.stringify({ password: "short" }), 902 }, 903 ); 904 905 expect(response.status).toBe(400); 906 const data = await response.json(); 907 expect(data.error).toBe("Invalid password format"); 908 }); 909 }); 910 911 describe("PUT /api/user/name", () => { 912 test("should update user name", async () => { 913 // Register user 914 const hashedPassword = await clientHashPassword( 915 TEST_USER.email, 916 TEST_USER.password, 917 ); 918 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 919 method: "POST", 920 headers: { "Content-Type": "application/json" }, 921 body: JSON.stringify({ 922 email: TEST_USER.email, 923 password: hashedPassword, 924 name: TEST_USER.name, 925 }), 926 }); 927 const sessionCookie = extractSessionCookie(registerResponse); 928 929 // Update name 930 const newName = "Updated Name"; 931 const response = await authRequest( 932 `${BASE_URL}/api/user/name`, 933 sessionCookie, 934 { 935 method: "PUT", 936 headers: { "Content-Type": "application/json" }, 937 body: JSON.stringify({ name: newName }), 938 }, 939 ); 940 941 expect(response.status).toBe(200); 942 const data = await response.json(); 943 expect(data.success).toBe(true); 944 945 // Verify name updated 946 const meResponse = await authRequest( 947 `${BASE_URL}/api/auth/me`, 948 sessionCookie, 949 ); 950 const meData = await meResponse.json(); 951 expect(meData.name).toBe(newName); 952 }); 953 954 test("should reject missing name", async () => { 955 // Register user 956 const hashedPassword = await clientHashPassword( 957 TEST_USER.email, 958 TEST_USER.password, 959 ); 960 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 961 method: "POST", 962 headers: { "Content-Type": "application/json" }, 963 body: JSON.stringify({ 964 email: TEST_USER.email, 965 password: hashedPassword, 966 }), 967 }); 968 const sessionCookie = extractSessionCookie(registerResponse); 969 970 const response = await authRequest( 971 `${BASE_URL}/api/user/name`, 972 sessionCookie, 973 { 974 method: "PUT", 975 headers: { "Content-Type": "application/json" }, 976 body: JSON.stringify({}), 977 }, 978 ); 979 980 expect(response.status).toBe(400); 981 }); 982 }); 983 984 describe("PUT /api/user/avatar", () => { 985 test("should update user avatar", async () => { 986 // Register user 987 const hashedPassword = await clientHashPassword( 988 TEST_USER.email, 989 TEST_USER.password, 990 ); 991 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 992 method: "POST", 993 headers: { "Content-Type": "application/json" }, 994 body: JSON.stringify({ 995 email: TEST_USER.email, 996 password: hashedPassword, 997 }), 998 }); 999 const sessionCookie = extractSessionCookie(registerResponse); 1000 1001 // Update avatar 1002 const newAvatar = "👨‍💻"; 1003 const response = await authRequest( 1004 `${BASE_URL}/api/user/avatar`, 1005 sessionCookie, 1006 { 1007 method: "PUT", 1008 headers: { "Content-Type": "application/json" }, 1009 body: JSON.stringify({ avatar: newAvatar }), 1010 }, 1011 ); 1012 1013 expect(response.status).toBe(200); 1014 const data = await response.json(); 1015 expect(data.success).toBe(true); 1016 1017 // Verify avatar updated 1018 const meResponse = await authRequest( 1019 `${BASE_URL}/api/auth/me`, 1020 sessionCookie, 1021 ); 1022 const meData = await meResponse.json(); 1023 expect(meData.avatar).toBe(newAvatar); 1024 }); 1025 }); 1026}); 1027 1028describe("API Endpoints - Health", () => { 1029 describe("GET /api/health", () => { 1030 test( 1031 "should return service health status with details", 1032 async () => { 1033 const response = await fetch(`${BASE_URL}/api/health`); 1034 1035 expect(response.status).toBe(200); 1036 const data = await response.json(); 1037 expect(data).toHaveProperty("status"); 1038 expect(data).toHaveProperty("timestamp"); 1039 expect(data).toHaveProperty("services"); 1040 expect(data.services).toHaveProperty("database"); 1041 expect(data.services).toHaveProperty("whisper"); 1042 expect(data.services).toHaveProperty("storage"); 1043 }, 1044 ); 1045 }); 1046}); 1047 1048describe("API Endpoints - Transcriptions", () => { 1049 describe("GET /api/transcriptions", () => { 1050 test("should return user transcriptions", async () => { 1051 // Register user 1052 const hashedPassword = await clientHashPassword( 1053 TEST_USER.email, 1054 TEST_USER.password, 1055 ); 1056 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1057 method: "POST", 1058 headers: { "Content-Type": "application/json" }, 1059 body: JSON.stringify({ 1060 email: TEST_USER.email, 1061 password: hashedPassword, 1062 }), 1063 }); 1064 const sessionCookie = extractSessionCookie(registerResponse); 1065 1066 // Get transcriptions 1067 const response = await authRequest( 1068 `${BASE_URL}/api/transcriptions`, 1069 sessionCookie, 1070 ); 1071 1072 expect(response.status).toBe(200); 1073 const data = await response.json(); 1074 expect(data.jobs).toBeDefined(); 1075 expect(Array.isArray(data.jobs)).toBe(true); 1076 }); 1077 1078 test("should require authentication", async () => { 1079 const response = await fetch(`${BASE_URL}/api/transcriptions`); 1080 1081 expect(response.status).toBe(401); 1082 }); 1083 }); 1084 1085 describe("POST /api/transcriptions", () => { 1086 test("should upload audio file and start transcription", async () => { 1087 // Register user 1088 const hashedPassword = await clientHashPassword( 1089 TEST_USER.email, 1090 TEST_USER.password, 1091 ); 1092 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1093 method: "POST", 1094 headers: { "Content-Type": "application/json" }, 1095 body: JSON.stringify({ 1096 email: TEST_USER.email, 1097 password: hashedPassword, 1098 }), 1099 }); 1100 const sessionCookie = extractSessionCookie(registerResponse); 1101 1102 // Create a test audio file 1103 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" }); 1104 const formData = new FormData(); 1105 formData.append("audio", audioBlob, "test.mp3"); 1106 formData.append("class_name", "Test Class"); 1107 1108 // Upload 1109 const response = await authRequest( 1110 `${BASE_URL}/api/transcriptions`, 1111 sessionCookie, 1112 { 1113 method: "POST", 1114 body: formData, 1115 }, 1116 ); 1117 1118 expect(response.status).toBe(200); 1119 const data = await response.json(); 1120 expect(data.id).toBeDefined(); 1121 expect(data.message).toContain("Upload successful"); 1122 }); 1123 1124 test("should reject non-audio files", async () => { 1125 // Register user 1126 const hashedPassword = await clientHashPassword( 1127 TEST_USER.email, 1128 TEST_USER.password, 1129 ); 1130 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1131 method: "POST", 1132 headers: { "Content-Type": "application/json" }, 1133 body: JSON.stringify({ 1134 email: TEST_USER.email, 1135 password: hashedPassword, 1136 }), 1137 }); 1138 const sessionCookie = extractSessionCookie(registerResponse); 1139 1140 // Try to upload non-audio file 1141 const textBlob = new Blob(["text file"], { type: "text/plain" }); 1142 const formData = new FormData(); 1143 formData.append("audio", textBlob, "test.txt"); 1144 1145 const response = await authRequest( 1146 `${BASE_URL}/api/transcriptions`, 1147 sessionCookie, 1148 { 1149 method: "POST", 1150 body: formData, 1151 }, 1152 ); 1153 1154 expect(response.status).toBe(400); 1155 }); 1156 1157 test("should reject files exceeding size limit", async () => { 1158 // Register user 1159 const hashedPassword = await clientHashPassword( 1160 TEST_USER.email, 1161 TEST_USER.password, 1162 ); 1163 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1164 method: "POST", 1165 headers: { "Content-Type": "application/json" }, 1166 body: JSON.stringify({ 1167 email: TEST_USER.email, 1168 password: hashedPassword, 1169 }), 1170 }); 1171 const sessionCookie = extractSessionCookie(registerResponse); 1172 1173 // Create a file larger than 100MB (the actual limit) 1174 const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], { 1175 type: "audio/mp3", 1176 }); 1177 const formData = new FormData(); 1178 formData.append("audio", largeBlob, "large.mp3"); 1179 1180 const response = await authRequest( 1181 `${BASE_URL}/api/transcriptions`, 1182 sessionCookie, 1183 { 1184 method: "POST", 1185 body: formData, 1186 }, 1187 ); 1188 1189 expect(response.status).toBe(400); 1190 const data = await response.json(); 1191 expect(data.error).toContain("File size must be less than"); 1192 }); 1193 1194 test("should require authentication", async () => { 1195 const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" }); 1196 const formData = new FormData(); 1197 formData.append("audio", audioBlob, "test.mp3"); 1198 1199 const response = await fetch(`${BASE_URL}/api/transcriptions`, { 1200 method: "POST", 1201 body: formData, 1202 }); 1203 1204 expect(response.status).toBe(401); 1205 }); 1206 }); 1207}); 1208 1209describe("API Endpoints - Admin", () => { 1210 let adminCookie: string; 1211 let userCookie: string; 1212 let userId: number; 1213 1214 beforeEach(async () => { 1215 1216 // Create admin user 1217 const adminHash = await clientHashPassword( 1218 TEST_ADMIN.email, 1219 TEST_ADMIN.password, 1220 ); 1221 const adminResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1222 method: "POST", 1223 headers: { "Content-Type": "application/json" }, 1224 body: JSON.stringify({ 1225 email: TEST_ADMIN.email, 1226 password: adminHash, 1227 name: TEST_ADMIN.name, 1228 }), 1229 }); 1230 adminCookie = extractSessionCookie(adminResponse); 1231 1232 // Manually set admin role in database 1233 db.run("UPDATE users SET role = 'admin' WHERE email = ?", [ 1234 TEST_ADMIN.email, 1235 ]); 1236 1237 // Create regular user 1238 const userHash = await clientHashPassword( 1239 TEST_USER.email, 1240 TEST_USER.password, 1241 ); 1242 const userResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1243 method: "POST", 1244 headers: { "Content-Type": "application/json" }, 1245 body: JSON.stringify({ 1246 email: TEST_USER.email, 1247 password: userHash, 1248 name: TEST_USER.name, 1249 }), 1250 }); 1251 userCookie = extractSessionCookie(userResponse); 1252 1253 // Get user ID 1254 const userIdResult = db 1255 .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?") 1256 .get(TEST_USER.email); 1257 userId = userIdResult?.id; 1258 }); 1259 1260 describe("GET /api/admin/users", () => { 1261 test("should return all users for admin", async () => { 1262 const response = await authRequest( 1263 `${BASE_URL}/api/admin/users`, 1264 adminCookie, 1265 ); 1266 1267 expect(response.status).toBe(200); 1268 const data = await response.json(); 1269 expect(Array.isArray(data)).toBe(true); 1270 expect(data.length).toBeGreaterThan(0); 1271 }); 1272 1273 test("should reject non-admin users", async () => { 1274 const response = await authRequest( 1275 `${BASE_URL}/api/admin/users`, 1276 userCookie, 1277 ); 1278 1279 expect(response.status).toBe(403); 1280 }); 1281 1282 test("should require authentication", async () => { 1283 const response = await fetch(`${BASE_URL}/api/admin/users`); 1284 1285 expect(response.status).toBe(401); 1286 }); 1287 }); 1288 1289 describe("GET /api/admin/transcriptions", () => { 1290 test("should return all transcriptions for admin", async () => { 1291 const response = await authRequest( 1292 `${BASE_URL}/api/admin/transcriptions`, 1293 adminCookie, 1294 ); 1295 1296 expect(response.status).toBe(200); 1297 const data = await response.json(); 1298 expect(Array.isArray(data)).toBe(true); 1299 }); 1300 1301 test("should reject non-admin users", async () => { 1302 const response = await authRequest( 1303 `${BASE_URL}/api/admin/transcriptions`, 1304 userCookie, 1305 ); 1306 1307 expect(response.status).toBe(403); 1308 }); 1309 }); 1310 1311 describe("DELETE /api/admin/users/:id", () => { 1312 test("should delete user as admin", async () => { 1313 const response = await authRequest( 1314 `${BASE_URL}/api/admin/users/${userId}`, 1315 adminCookie, 1316 { 1317 method: "DELETE", 1318 }, 1319 ); 1320 1321 expect(response.status).toBe(200); 1322 const data = await response.json(); 1323 expect(data.success).toBe(true); 1324 1325 // Verify user is deleted 1326 const verifyResponse = await authRequest( 1327 `${BASE_URL}/api/auth/me`, 1328 userCookie, 1329 ); 1330 expect(verifyResponse.status).toBe(401); 1331 }); 1332 1333 test("should reject non-admin users", async () => { 1334 const response = await authRequest( 1335 `${BASE_URL}/api/admin/users/${userId}`, 1336 userCookie, 1337 { 1338 method: "DELETE", 1339 }, 1340 ); 1341 1342 expect(response.status).toBe(403); 1343 }); 1344 }); 1345 1346 describe("PUT /api/admin/users/:id/role", () => { 1347 test("should update user role as admin", async () => { 1348 const response = await authRequest( 1349 `${BASE_URL}/api/admin/users/${userId}/role`, 1350 adminCookie, 1351 { 1352 method: "PUT", 1353 headers: { "Content-Type": "application/json" }, 1354 body: JSON.stringify({ role: "admin" }), 1355 }, 1356 ); 1357 1358 expect(response.status).toBe(200); 1359 const data = await response.json(); 1360 expect(data.success).toBe(true); 1361 1362 // Verify role updated 1363 const meResponse = await authRequest( 1364 `${BASE_URL}/api/auth/me`, 1365 userCookie, 1366 ); 1367 const meData = await meResponse.json(); 1368 expect(meData.role).toBe("admin"); 1369 }); 1370 1371 test("should reject invalid roles", async () => { 1372 const response = await authRequest( 1373 `${BASE_URL}/api/admin/users/${userId}/role`, 1374 adminCookie, 1375 { 1376 method: "PUT", 1377 headers: { "Content-Type": "application/json" }, 1378 body: JSON.stringify({ role: "superadmin" }), 1379 }, 1380 ); 1381 1382 expect(response.status).toBe(400); 1383 }); 1384 }); 1385 1386 describe("GET /api/admin/users/:id/details", () => { 1387 test("should return user details for admin", async () => { 1388 const response = await authRequest( 1389 `${BASE_URL}/api/admin/users/${userId}/details`, 1390 adminCookie, 1391 ); 1392 1393 expect(response.status).toBe(200); 1394 const data = await response.json(); 1395 expect(data.id).toBe(userId); 1396 expect(data.email).toBe(TEST_USER.email); 1397 expect(data).toHaveProperty("passkeys"); 1398 expect(data).toHaveProperty("sessions"); 1399 }); 1400 1401 test("should reject non-admin users", async () => { 1402 const response = await authRequest( 1403 `${BASE_URL}/api/admin/users/${userId}/details`, 1404 userCookie, 1405 ); 1406 1407 expect(response.status).toBe(403); 1408 }); 1409 }); 1410 1411 describe("PUT /api/admin/users/:id/name", () => { 1412 test("should update user name as admin", async () => { 1413 const newName = "Admin Updated Name"; 1414 const response = await authRequest( 1415 `${BASE_URL}/api/admin/users/${userId}/name`, 1416 adminCookie, 1417 { 1418 method: "PUT", 1419 headers: { "Content-Type": "application/json" }, 1420 body: JSON.stringify({ name: newName }), 1421 }, 1422 ); 1423 1424 expect(response.status).toBe(200); 1425 const data = await response.json(); 1426 expect(data.success).toBe(true); 1427 }); 1428 1429 test("should reject empty names", async () => { 1430 const response = await authRequest( 1431 `${BASE_URL}/api/admin/users/${userId}/name`, 1432 adminCookie, 1433 { 1434 method: "PUT", 1435 headers: { "Content-Type": "application/json" }, 1436 body: JSON.stringify({ name: "" }), 1437 }, 1438 ); 1439 1440 expect(response.status).toBe(400); 1441 }); 1442 }); 1443 1444 describe("PUT /api/admin/users/:id/email", () => { 1445 test("should update user email as admin", async () => { 1446 const newEmail = "newemail@admin.com"; 1447 const response = await authRequest( 1448 `${BASE_URL}/api/admin/users/${userId}/email`, 1449 adminCookie, 1450 { 1451 method: "PUT", 1452 headers: { "Content-Type": "application/json" }, 1453 body: JSON.stringify({ email: newEmail }), 1454 }, 1455 ); 1456 1457 expect(response.status).toBe(200); 1458 const data = await response.json(); 1459 expect(data.success).toBe(true); 1460 }); 1461 1462 test("should reject duplicate emails", async () => { 1463 const response = await authRequest( 1464 `${BASE_URL}/api/admin/users/${userId}/email`, 1465 adminCookie, 1466 { 1467 method: "PUT", 1468 headers: { "Content-Type": "application/json" }, 1469 body: JSON.stringify({ email: TEST_ADMIN.email }), 1470 }, 1471 ); 1472 1473 expect(response.status).toBe(400); 1474 const data = await response.json(); 1475 expect(data.error).toBe("Email already in use"); 1476 }); 1477 }); 1478 1479 describe("GET /api/admin/users/:id/sessions", () => { 1480 test("should return user sessions as admin", async () => { 1481 const response = await authRequest( 1482 `${BASE_URL}/api/admin/users/${userId}/sessions`, 1483 adminCookie, 1484 ); 1485 1486 expect(response.status).toBe(200); 1487 const data = await response.json(); 1488 expect(Array.isArray(data)).toBe(true); 1489 }); 1490 }); 1491 1492 describe("DELETE /api/admin/users/:id/sessions", () => { 1493 test("should delete all user sessions as admin", async () => { 1494 const response = await authRequest( 1495 `${BASE_URL}/api/admin/users/${userId}/sessions`, 1496 adminCookie, 1497 { 1498 method: "DELETE", 1499 }, 1500 ); 1501 1502 expect(response.status).toBe(200); 1503 const data = await response.json(); 1504 expect(data.success).toBe(true); 1505 1506 // Verify sessions are deleted 1507 const verifyResponse = await authRequest( 1508 `${BASE_URL}/api/auth/me`, 1509 userCookie, 1510 ); 1511 expect(verifyResponse.status).toBe(401); 1512 }); 1513 }); 1514}); 1515 1516describe("API Endpoints - Passkeys", () => { 1517 let sessionCookie: string; 1518 1519 beforeEach(async () => { 1520 1521 // Register user 1522 const hashedPassword = await clientHashPassword( 1523 TEST_USER.email, 1524 TEST_USER.password, 1525 ); 1526 const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, { 1527 method: "POST", 1528 headers: { "Content-Type": "application/json" }, 1529 body: JSON.stringify({ 1530 email: TEST_USER.email, 1531 password: hashedPassword, 1532 }), 1533 }); 1534 sessionCookie = extractSessionCookie(registerResponse); 1535 }); 1536 1537 describe("GET /api/passkeys", () => { 1538 test("should return user passkeys", async () => { 1539 const response = await authRequest( 1540 `${BASE_URL}/api/passkeys`, 1541 sessionCookie, 1542 ); 1543 1544 expect(response.status).toBe(200); 1545 const data = await response.json(); 1546 expect(data.passkeys).toBeDefined(); 1547 expect(Array.isArray(data.passkeys)).toBe(true); 1548 }); 1549 1550 test("should require authentication", async () => { 1551 const response = await fetch(`${BASE_URL}/api/passkeys`); 1552 1553 expect(response.status).toBe(401); 1554 }); 1555 }); 1556 1557 describe("POST /api/passkeys/register/options", () => { 1558 test( 1559 "should return registration options for authenticated user", 1560 async () => { 1561 const response = await authRequest( 1562 `${BASE_URL}/api/passkeys/register/options`, 1563 sessionCookie, 1564 { 1565 method: "POST", 1566 }, 1567 ); 1568 1569 expect(response.status).toBe(200); 1570 const data = await response.json(); 1571 expect(data).toHaveProperty("challenge"); 1572 expect(data).toHaveProperty("rp"); 1573 expect(data).toHaveProperty("user"); 1574 }, 1575 ); 1576 1577 test("should require authentication", async () => { 1578 const response = await fetch( 1579 `${BASE_URL}/api/passkeys/register/options`, 1580 { 1581 method: "POST", 1582 }, 1583 ); 1584 1585 expect(response.status).toBe(401); 1586 }); 1587 }); 1588 1589 describe("POST /api/passkeys/authenticate/options", () => { 1590 test("should return authentication options for email", async () => { 1591 const response = await fetch( 1592 `${BASE_URL}/api/passkeys/authenticate/options`, 1593 { 1594 method: "POST", 1595 headers: { "Content-Type": "application/json" }, 1596 body: JSON.stringify({ email: TEST_USER.email }), 1597 }, 1598 ); 1599 1600 expect(response.status).toBe(200); 1601 const data = await response.json(); 1602 expect(data).toHaveProperty("challenge"); 1603 }); 1604 1605 test("should handle non-existent email", async () => { 1606 const response = await fetch( 1607 `${BASE_URL}/api/passkeys/authenticate/options`, 1608 { 1609 method: "POST", 1610 headers: { "Content-Type": "application/json" }, 1611 body: JSON.stringify({ email: "nonexistent@example.com" }), 1612 }, 1613 ); 1614 1615 // Should still return options for privacy (don't leak user existence) 1616 expect([200, 404]).toContain(response.status); 1617 }); 1618 }); 1619});