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