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