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