kaboot/tests/hooks/useGame.draftPersistence.test.tsx
Joey Yakimowich-Payne e480ad06df
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
2026-01-15 10:12:05 -07:00

782 lines
24 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import type { Quiz } from '../../types';
import { DEFAULT_GAME_CONFIG } from '../../types';
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
interface DraftQuiz {
quiz: Quiz;
topic?: string;
sourceQuizId?: string;
}
const createMockQuiz = (overrides?: Partial<Quiz>): Quiz => ({
title: 'Test Quiz',
questions: [
{
id: 'q1',
text: 'What is 2+2?',
timeLimit: 20,
options: [
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
{ text: '5', isCorrect: false, shape: 'circle', color: 'yellow' },
{ text: '6', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
...overrides,
});
describe('Draft Quiz Persistence', () => {
let storage: Map<string, string>;
const getDraftQuiz = (): DraftQuiz | null => {
try {
const stored = storage.get(DRAFT_QUIZ_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
};
const storeDraftQuiz = (draft: DraftQuiz) => {
storage.set(DRAFT_QUIZ_KEY, JSON.stringify(draft));
};
const clearDraftQuiz = () => {
storage.delete(DRAFT_QUIZ_KEY);
};
beforeEach(() => {
storage = new Map<string, string>();
});
describe('Draft Storage Operations', () => {
it('should store a draft quiz', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, topic: 'Math' });
expect(storage.has(DRAFT_QUIZ_KEY)).toBe(true);
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('Test Quiz');
expect(stored?.topic).toBe('Math');
});
it('should store draft quiz with sourceQuizId for library quizzes', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, sourceQuizId: 'quiz-abc-123' });
const stored = getDraftQuiz();
expect(stored?.sourceQuizId).toBe('quiz-abc-123');
expect(stored?.topic).toBeUndefined();
});
it('should retrieve null when no draft exists', () => {
const stored = getDraftQuiz();
expect(stored).toBeNull();
});
it('should clear draft quiz', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz });
clearDraftQuiz();
expect(storage.has(DRAFT_QUIZ_KEY)).toBe(false);
expect(getDraftQuiz()).toBeNull();
});
it('should overwrite existing draft', () => {
const quiz1 = createMockQuiz({ title: 'First Quiz' });
const quiz2 = createMockQuiz({ title: 'Second Quiz' });
storeDraftQuiz({ quiz: quiz1, topic: 'Topic 1' });
storeDraftQuiz({ quiz: quiz2, topic: 'Topic 2' });
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('Second Quiz');
expect(stored?.topic).toBe('Topic 2');
});
it('should preserve quiz config in draft', () => {
const quizWithConfig = createMockQuiz({
config: {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
hostParticipates: false,
},
});
storeDraftQuiz({ quiz: quizWithConfig });
const stored = getDraftQuiz();
expect(stored?.quiz.config?.shuffleQuestions).toBe(true);
expect(stored?.quiz.config?.hostParticipates).toBe(false);
});
});
describe('URL Path Generation for Editing', () => {
const getEditTargetPath = (sourceQuizId: string | null): string => {
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
};
it('should return /edit/draft when no sourceQuizId', () => {
expect(getEditTargetPath(null)).toBe('/edit/draft');
});
it('should return /edit/:quizId when sourceQuizId exists', () => {
expect(getEditTargetPath('quiz-123')).toBe('/edit/quiz-123');
});
it('should handle UUID-style quiz IDs', () => {
expect(getEditTargetPath('a1b2c3d4-e5f6-7890-abcd-ef1234567890'))
.toBe('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});
it('should handle short alphanumeric IDs', () => {
expect(getEditTargetPath('abc123')).toBe('/edit/abc123');
});
});
describe('URL Initialization for Edit Routes', () => {
interface InitResult {
action: string;
quiz?: Quiz;
sourceQuizId?: string | null;
topic?: string;
}
const simulateEditUrlInit = (
path: string,
draftQuiz: DraftQuiz | null
): InitResult => {
if (path === '/edit/draft') {
if (draftQuiz) {
return {
action: 'restoreDraft',
quiz: draftQuiz.quiz,
sourceQuizId: null,
topic: draftQuiz.topic,
};
}
return { action: 'navigateHome' };
}
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
if (editMatch) {
const quizId = editMatch[1];
if (draftQuiz && draftQuiz.sourceQuizId === quizId) {
return {
action: 'restoreLibraryQuiz',
quiz: draftQuiz.quiz,
sourceQuizId: quizId,
};
}
return { action: 'navigateHome' };
}
return { action: 'noMatch' };
};
describe('/edit/draft URL', () => {
it('should restore draft when draft exists in storage', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, topic: 'Science' };
const result = simulateEditUrlInit('/edit/draft', draft);
expect(result.action).toBe('restoreDraft');
expect(result.quiz?.title).toBe('Test Quiz');
expect(result.sourceQuizId).toBeNull();
expect(result.topic).toBe('Science');
});
it('should navigate home when no draft exists', () => {
const result = simulateEditUrlInit('/edit/draft', null);
expect(result.action).toBe('navigateHome');
});
it('should restore draft without topic (manual creation)', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz };
const result = simulateEditUrlInit('/edit/draft', draft);
expect(result.action).toBe('restoreDraft');
expect(result.topic).toBeUndefined();
});
});
describe('/edit/:quizId URL', () => {
it('should restore library quiz when quizId matches', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, sourceQuizId: 'quiz-456' };
const result = simulateEditUrlInit('/edit/quiz-456', draft);
expect(result.action).toBe('restoreLibraryQuiz');
expect(result.quiz?.title).toBe('Test Quiz');
expect(result.sourceQuizId).toBe('quiz-456');
});
it('should navigate home when quizId does not match stored draft', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, sourceQuizId: 'quiz-789' };
const result = simulateEditUrlInit('/edit/quiz-456', draft);
expect(result.action).toBe('navigateHome');
});
it('should navigate home when no draft exists', () => {
const result = simulateEditUrlInit('/edit/quiz-456', null);
expect(result.action).toBe('navigateHome');
});
it('should navigate home when draft has no sourceQuizId', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, topic: 'AI Generated' };
const result = simulateEditUrlInit('/edit/quiz-456', draft);
expect(result.action).toBe('navigateHome');
});
});
describe('URL pattern matching', () => {
it('should match UUID-style quiz IDs', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, sourceQuizId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' };
const result = simulateEditUrlInit('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', draft);
expect(result.action).toBe('restoreLibraryQuiz');
});
it('should match alphanumeric IDs', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, sourceQuizId: 'abc123XYZ' };
const result = simulateEditUrlInit('/edit/abc123XYZ', draft);
expect(result.action).toBe('restoreLibraryQuiz');
});
it('should not match IDs with special characters', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz, sourceQuizId: 'quiz@123' };
const result = simulateEditUrlInit('/edit/quiz@123', draft);
expect(result.action).toBe('noMatch');
});
it('should not match /edit without ID', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz };
const result = simulateEditUrlInit('/edit', draft);
expect(result.action).toBe('noMatch');
});
it('should not match /edit/ with trailing slash only', () => {
const quiz = createMockQuiz();
const draft: DraftQuiz = { quiz };
const result = simulateEditUrlInit('/edit/', draft);
expect(result.action).toBe('noMatch');
});
});
});
describe('Draft Lifecycle Operations', () => {
describe('loadSavedQuiz (library quiz)', () => {
it('should store quiz with sourceQuizId when loading from library', () => {
const quiz = createMockQuiz({ title: 'Library Quiz' });
const quizId = 'lib-quiz-123';
storeDraftQuiz({ quiz, sourceQuizId: quizId });
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('Library Quiz');
expect(stored?.sourceQuizId).toBe(quizId);
expect(stored?.topic).toBeUndefined();
});
});
describe('startQuizGen (AI generated quiz)', () => {
it('should store quiz with topic when AI generates quiz', () => {
const quiz = createMockQuiz({ title: 'AI Quiz' });
const topic = 'Ancient History';
storeDraftQuiz({ quiz, topic });
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('AI Quiz');
expect(stored?.topic).toBe(topic);
expect(stored?.sourceQuizId).toBeUndefined();
});
it('should store quiz with file names as topic', () => {
const quiz = createMockQuiz();
const topic = 'document1.pdf, document2.pdf';
storeDraftQuiz({ quiz, topic });
const stored = getDraftQuiz();
expect(stored?.topic).toBe('document1.pdf, document2.pdf');
});
});
describe('updateQuizFromEditor', () => {
it('should update stored draft when quiz is edited', () => {
const originalQuiz = createMockQuiz({ title: 'Original' });
storeDraftQuiz({ quiz: originalQuiz, topic: 'Topic' });
const updatedQuiz = createMockQuiz({ title: 'Updated' });
const currentDraft = getDraftQuiz();
storeDraftQuiz({
quiz: updatedQuiz,
topic: currentDraft?.topic,
sourceQuizId: currentDraft?.sourceQuizId,
});
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('Updated');
expect(stored?.topic).toBe('Topic');
});
it('should preserve sourceQuizId when updating library quiz', () => {
const originalQuiz = createMockQuiz({ title: 'Library Original' });
storeDraftQuiz({ quiz: originalQuiz, sourceQuizId: 'lib-123' });
const updatedQuiz = createMockQuiz({ title: 'Library Updated' });
const currentDraft = getDraftQuiz();
storeDraftQuiz({
quiz: updatedQuiz,
topic: currentDraft?.topic,
sourceQuizId: currentDraft?.sourceQuizId,
});
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('Library Updated');
expect(stored?.sourceQuizId).toBe('lib-123');
});
});
describe('startGameFromEditor', () => {
it('should clear draft when starting game', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, topic: 'Test' });
expect(getDraftQuiz()).not.toBeNull();
clearDraftQuiz();
expect(getDraftQuiz()).toBeNull();
});
});
describe('backFromEditor', () => {
it('should clear draft when going back from editor', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, sourceQuizId: 'lib-456' });
expect(getDraftQuiz()).not.toBeNull();
clearDraftQuiz();
expect(getDraftQuiz()).toBeNull();
});
});
});
describe('Non-Happy Path Scenarios', () => {
describe('Corrupted storage', () => {
it('should return null for corrupted JSON', () => {
storage.set(DRAFT_QUIZ_KEY, 'not valid json {{{');
const stored = getDraftQuiz();
expect(stored).toBeNull();
});
it('should return null for empty string', () => {
storage.set(DRAFT_QUIZ_KEY, '');
const stored = getDraftQuiz();
expect(stored).toBeNull();
});
it('should handle storage with wrong structure gracefully', () => {
storage.set(DRAFT_QUIZ_KEY, JSON.stringify({ wrong: 'structure' }));
const stored = getDraftQuiz();
expect(stored).not.toBeNull();
expect(stored?.quiz).toBeUndefined();
});
});
describe('Edge cases', () => {
it('should handle quiz with empty questions array', () => {
const quiz = createMockQuiz({ questions: [] });
storeDraftQuiz({ quiz });
const stored = getDraftQuiz();
expect(stored?.quiz.questions).toHaveLength(0);
});
it('should handle quiz with empty title', () => {
const quiz = createMockQuiz({ title: '' });
storeDraftQuiz({ quiz });
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('');
});
it('should handle empty topic string', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, topic: '' });
const stored = getDraftQuiz();
expect(stored?.topic).toBe('');
});
it('should handle very long quiz content', () => {
const longText = 'A'.repeat(10000);
const quiz = createMockQuiz({
title: longText,
questions: Array(100).fill(null).map((_, i) => ({
id: `q${i}`,
text: longText,
timeLimit: 20,
options: [
{ text: longText, isCorrect: true, shape: 'triangle' as const, color: 'red' as const },
],
})),
});
storeDraftQuiz({ quiz });
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe(longText);
expect(stored?.quiz.questions).toHaveLength(100);
});
});
describe('Stale draft detection', () => {
it('should detect when URL quizId does not match stored sourceQuizId', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, sourceQuizId: 'old-quiz-id' });
const draft = getDraftQuiz();
const urlQuizId = 'new-quiz-id';
const isStale = draft?.sourceQuizId !== urlQuizId;
expect(isStale).toBe(true);
});
it('should detect when accessing /edit/:id but draft is for AI quiz (no sourceQuizId)', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, topic: 'AI Generated' });
const draft = getDraftQuiz();
const urlQuizId = 'some-quiz-id';
const canRestore = draft?.sourceQuizId === urlQuizId;
expect(canRestore).toBe(false);
});
it('should detect when accessing /edit/draft but draft has sourceQuizId', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz, sourceQuizId: 'lib-quiz' });
const draft = getDraftQuiz();
const isDraftRoute = true;
const hasSourceId = !!draft?.sourceQuizId;
expect(hasSourceId).toBe(true);
});
});
describe('Multiple browser tabs', () => {
it('should overwrite draft from another tab', () => {
const quiz1 = createMockQuiz({ title: 'Tab 1 Quiz' });
storeDraftQuiz({ quiz: quiz1, topic: 'Tab 1' });
const quiz2 = createMockQuiz({ title: 'Tab 2 Quiz' });
storeDraftQuiz({ quiz: quiz2, topic: 'Tab 2' });
const stored = getDraftQuiz();
expect(stored?.quiz.title).toBe('Tab 2 Quiz');
});
});
describe('Page refresh timing', () => {
it('should restore draft immediately on page load', () => {
const quiz = createMockQuiz({ title: 'Refreshed Quiz' });
storeDraftQuiz({ quiz, topic: 'Refresh Test' });
const stored = getDraftQuiz();
expect(stored).not.toBeNull();
expect(stored?.quiz.title).toBe('Refreshed Quiz');
});
it('should handle refresh when storage was cleared externally', () => {
const quiz = createMockQuiz();
storeDraftQuiz({ quiz });
storage.clear();
const stored = getDraftQuiz();
expect(stored).toBeNull();
});
});
});
describe('Integration: Full Edit Flow', () => {
it('should support full AI quiz edit flow: generate -> edit -> refresh -> continue', () => {
const generatedQuiz = createMockQuiz({ title: 'AI Generated Quiz' });
storeDraftQuiz({ quiz: generatedQuiz, topic: 'Physics' });
const afterRefresh = getDraftQuiz();
expect(afterRefresh?.quiz.title).toBe('AI Generated Quiz');
expect(afterRefresh?.topic).toBe('Physics');
expect(afterRefresh?.sourceQuizId).toBeUndefined();
const editedQuiz = createMockQuiz({ title: 'AI Generated Quiz - Edited' });
storeDraftQuiz({
quiz: editedQuiz,
topic: afterRefresh?.topic,
sourceQuizId: afterRefresh?.sourceQuizId,
});
const afterEdit = getDraftQuiz();
expect(afterEdit?.quiz.title).toBe('AI Generated Quiz - Edited');
clearDraftQuiz();
expect(getDraftQuiz()).toBeNull();
});
it('should support full library quiz edit flow: load -> edit -> refresh -> continue', () => {
const libraryQuiz = createMockQuiz({ title: 'My Saved Quiz' });
const quizId = 'saved-quiz-abc';
storeDraftQuiz({ quiz: libraryQuiz, sourceQuizId: quizId });
const afterRefresh = getDraftQuiz();
expect(afterRefresh?.quiz.title).toBe('My Saved Quiz');
expect(afterRefresh?.sourceQuizId).toBe(quizId);
const editedQuiz = createMockQuiz({ title: 'My Saved Quiz - Updated' });
storeDraftQuiz({
quiz: editedQuiz,
topic: afterRefresh?.topic,
sourceQuizId: afterRefresh?.sourceQuizId,
});
const afterEdit = getDraftQuiz();
expect(afterEdit?.quiz.title).toBe('My Saved Quiz - Updated');
expect(afterEdit?.sourceQuizId).toBe(quizId);
clearDraftQuiz();
expect(getDraftQuiz()).toBeNull();
});
});
});
describe('Edit Route Authentication Protection', () => {
interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
}
const shouldRedirectToAuth = (
pathname: string,
auth: AuthState
): { redirect: boolean; action?: 'signin' | 'wait' | 'allow' } => {
const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(pathname) &&
pathname !== '/edit/draft';
if (!isLibraryQuizRoute) {
return { redirect: false, action: 'allow' };
}
if (auth.isLoading) {
return { redirect: false, action: 'wait' };
}
if (!auth.isAuthenticated) {
return { redirect: true, action: 'signin' };
}
return { redirect: false, action: 'allow' };
};
describe('Library quiz routes (/edit/:quizId)', () => {
it('should require auth for library quiz routes', () => {
const result = shouldRedirectToAuth('/edit/quiz-123', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(true);
expect(result.action).toBe('signin');
});
it('should allow authenticated users to view library quizzes', () => {
const result = shouldRedirectToAuth('/edit/quiz-123', {
isLoading: false,
isAuthenticated: true,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('allow');
});
it('should wait while auth is loading', () => {
const result = shouldRedirectToAuth('/edit/quiz-123', {
isLoading: true,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('wait');
});
it('should require auth for UUID-style quiz IDs', () => {
const result = shouldRedirectToAuth('/edit/a1b2c3d4-e5f6-7890-abcd-ef1234567890', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(true);
expect(result.action).toBe('signin');
});
});
describe('Draft routes (/edit/draft)', () => {
it('should NOT require auth for draft route', () => {
const result = shouldRedirectToAuth('/edit/draft', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('allow');
});
it('should allow unauthenticated users to view drafts', () => {
const result = shouldRedirectToAuth('/edit/draft', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
});
});
describe('Non-edit routes', () => {
it('should NOT require auth for landing page', () => {
const result = shouldRedirectToAuth('/', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('allow');
});
it('should NOT require auth for create route', () => {
const result = shouldRedirectToAuth('/create', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('allow');
});
it('should NOT require auth for play route', () => {
const result = shouldRedirectToAuth('/play/123456', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('allow');
});
it('should NOT require auth for host route', () => {
const result = shouldRedirectToAuth('/host/123456', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
expect(result.action).toBe('allow');
});
});
describe('Auth state transitions', () => {
it('should redirect after auth finishes loading (unauthenticated)', () => {
const auth = { isLoading: true, isAuthenticated: false };
const beforeLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
expect(beforeLoad.action).toBe('wait');
auth.isLoading = false;
const afterLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
expect(afterLoad.redirect).toBe(true);
expect(afterLoad.action).toBe('signin');
});
it('should allow access after auth finishes loading (authenticated)', () => {
const auth = { isLoading: true, isAuthenticated: false };
const beforeLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
expect(beforeLoad.action).toBe('wait');
auth.isLoading = false;
auth.isAuthenticated = true;
const afterLoad = shouldRedirectToAuth('/edit/quiz-123', auth);
expect(afterLoad.redirect).toBe(false);
expect(afterLoad.action).toBe('allow');
});
});
describe('Edge cases', () => {
it('should handle /edit without trailing path', () => {
const result = shouldRedirectToAuth('/edit', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
});
it('should handle /edit/ with trailing slash', () => {
const result = shouldRedirectToAuth('/edit/', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
});
it('should handle special characters in URL (should not match)', () => {
const result = shouldRedirectToAuth('/edit/quiz@123', {
isLoading: false,
isAuthenticated: false,
});
expect(result.redirect).toBe(false);
});
});
});