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:
parent
75c496e68f
commit
e480ad06df
18 changed files with 1775 additions and 94 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue