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