Add server security hardening and draft quiz persistence

Security:
- Add AES-256-GCM encryption for user PII (email, API keys, config)
- Add rate limiting (helmet + express-rate-limit)
- Require auth for file uploads

UX:
- Persist draft quizzes to sessionStorage (survives refresh)
- Add URL-based edit routes (/edit/draft, /edit/:quizId)
- Fix QuizEditor async defaultConfig race condition
- Fix URL param accumulation in Landing
This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 10:12:05 -07:00
commit e480ad06df
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
18 changed files with 1775 additions and 94 deletions

View file

@ -6,7 +6,8 @@ describe('URL Routing and Navigation', () => {
gameState: string,
gamePin: string | null,
role: 'HOST' | 'CLIENT',
currentPath: string
currentPath: string,
sourceQuizId: string | null = null
): string => {
switch (gameState) {
case 'LANDING':
@ -18,7 +19,7 @@ describe('URL Routing and Navigation', () => {
case 'GENERATING':
return '/create';
case 'EDITING':
return '/edit';
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
case 'LOBBY':
case 'COUNTDOWN':
case 'QUESTION':
@ -66,8 +67,17 @@ describe('URL Routing and Navigation', () => {
});
describe('EDITING state', () => {
it('should return "/edit" for EDITING state', () => {
expect(getTargetPath('EDITING', null, 'HOST', '/')).toBe('/edit');
it('should return "/edit/draft" for EDITING state without sourceQuizId', () => {
expect(getTargetPath('EDITING', null, 'HOST', '/', null)).toBe('/edit/draft');
});
it('should return "/edit/:quizId" for EDITING state with sourceQuizId', () => {
expect(getTargetPath('EDITING', null, 'HOST', '/', 'quiz-123')).toBe('/edit/quiz-123');
});
it('should handle UUID-style sourceQuizId', () => {
expect(getTargetPath('EDITING', null, 'HOST', '/', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'))
.toBe('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});
});
@ -128,7 +138,7 @@ describe('URL Routing and Navigation', () => {
const simulateUrlInit = (
path: string,
storedSession: StoredSession | null
): { action: string; gamePin?: string; shouldReconnect?: boolean } => {
): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => {
const hostMatch = path.match(/^\/host\/(\d+)$/);
const playMatch = path.match(/^\/play\/(\d+)$/);
@ -152,11 +162,14 @@ describe('URL Routing and Navigation', () => {
return { action: 'startCreating' };
}
if (path === '/edit') {
if (!storedSession) {
return { action: 'navigateHome' };
}
return { action: 'continueEditing' };
if (path === '/edit/draft') {
return { action: 'restoreDraft' };
}
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
if (editMatch) {
const quizId = editMatch[1];
return { action: 'restoreLibraryQuiz', quizId };
}
if (storedSession) {
@ -230,16 +243,35 @@ describe('URL Routing and Navigation', () => {
});
});
describe('/edit URL', () => {
it('should navigate home when no session', () => {
const result = simulateUrlInit('/edit', null);
expect(result.action).toBe('navigateHome');
describe('/edit/draft URL', () => {
it('should restore draft for AI-generated quizzes', () => {
const result = simulateUrlInit('/edit/draft', null);
expect(result.action).toBe('restoreDraft');
});
it('should continue editing when session exists', () => {
it('should restore draft regardless of session state', () => {
const session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
const result = simulateUrlInit('/edit', session);
expect(result.action).toBe('continueEditing');
const result = simulateUrlInit('/edit/draft', session);
expect(result.action).toBe('restoreDraft');
});
});
describe('/edit/:quizId URL', () => {
it('should restore library quiz with quizId', () => {
const result = simulateUrlInit('/edit/quiz-abc-123', null);
expect(result.action).toBe('restoreLibraryQuiz');
expect(result.quizId).toBe('quiz-abc-123');
});
it('should handle UUID-style quiz IDs', () => {
const result = simulateUrlInit('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', null);
expect(result.action).toBe('restoreLibraryQuiz');
expect(result.quizId).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});
it('should not match /edit without ID', () => {
const result = simulateUrlInit('/edit', null);
expect(result.action).toBe('showLanding');
});
});
@ -592,14 +624,33 @@ describe('URL Routing and Navigation', () => {
expect(gamePin).toBe('123456');
});
it('should redirect to home when accessing /edit without any session', () => {
const path = '/edit';
const session = null;
it('should redirect to home when accessing /edit/draft without draft in storage', () => {
const path = '/edit/draft';
const hasDraftInStorage = false;
let action = 'none';
if (path === '/edit') {
if (!session) {
if (path === '/edit/draft') {
if (!hasDraftInStorage) {
action = 'navigateHome';
} else {
action = 'restoreDraft';
}
}
expect(action).toBe('navigateHome');
});
it('should redirect to home when accessing /edit/:quizId with mismatched storage', () => {
const path = '/edit/quiz-123';
const storedQuizId = 'quiz-456';
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
let action = 'none';
if (editMatch) {
const urlQuizId = editMatch[1];
if (storedQuizId !== urlQuizId) {
action = 'navigateHome';
}
}
@ -1012,7 +1063,8 @@ describe('OAuth Callback Handling', () => {
it('should NOT skip URL initialization on other paths', () => {
expect(shouldSkipUrlInit('/')).toBe(false);
expect(shouldSkipUrlInit('/create')).toBe(false);
expect(shouldSkipUrlInit('/edit')).toBe(false);
expect(shouldSkipUrlInit('/edit/draft')).toBe(false);
expect(shouldSkipUrlInit('/edit/quiz-123')).toBe(false);
expect(shouldSkipUrlInit('/host/123456')).toBe(false);
expect(shouldSkipUrlInit('/play/123456')).toBe(false);
});
@ -1031,12 +1083,12 @@ describe('OAuth Callback Handling', () => {
expect(urlInitRan).toBe(true);
});
it('should prevent URL sync from running during OAuth flow', () => {
it('should prevent URL sync from running while auth is loading', () => {
let urlSyncRan = false;
const pathname = '/callback';
const authIsLoading = true;
const syncUrl = () => {
if (pathname === '/callback') return;
if (authIsLoading) return;
urlSyncRan = true;
};
@ -1044,6 +1096,21 @@ describe('OAuth Callback Handling', () => {
expect(urlSyncRan).toBe(false);
});
it('should navigate away from /callback after auth completes', () => {
const pathname = '/callback';
const authIsLoading = false;
const getTargetPath = () => {
if (pathname === '/callback') {
return '/';
}
return pathname;
};
const targetPath = getTargetPath();
expect(targetPath).toBe('/');
});
});
describe('localStorage preservation during OAuth', () => {