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

@ -0,0 +1,495 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QuizEditor } from '../../components/QuizEditor';
import { DEFAULT_GAME_CONFIG } from '../../types';
import type { Quiz, GameConfig } from '../../types';
vi.mock('uuid', () => ({
v4: () => 'mock-uuid-' + Math.random().toString(36).substr(2, 9),
}));
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('QuizEditor - Async Default Config Loading', () => {
const mockOnSave = vi.fn();
const mockOnStartGame = vi.fn();
const mockOnConfigChange = vi.fn();
const mockOnBack = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const renderEditor = (props: Partial<React.ComponentProps<typeof QuizEditor>> = {}) => {
const defaultProps = {
quiz: createMockQuiz(),
onSave: mockOnSave,
onStartGame: mockOnStartGame,
onConfigChange: mockOnConfigChange,
onBack: mockOnBack,
showSaveButton: true,
isSaving: false,
};
return render(<QuizEditor {...defaultProps} {...props} />);
};
describe('Initial config state', () => {
it('should use DEFAULT_GAME_CONFIG when quiz has no config and no defaultConfig prop', async () => {
const user = userEvent.setup();
renderEditor();
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: DEFAULT_GAME_CONFIG,
}),
DEFAULT_GAME_CONFIG
);
});
it('should use quiz.config when provided, ignoring defaultConfig prop', async () => {
const user = userEvent.setup();
const quizConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
hostParticipates: false,
};
renderEditor({
quiz: createMockQuiz({ config: quizConfig }),
defaultConfig: {
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
},
});
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
hostParticipates: false,
shuffleAnswers: false,
}),
}),
expect.any(Object)
);
});
});
describe('Async defaultConfig loading (race condition fix)', () => {
it('should apply defaultConfig when it loads after initial render', async () => {
const user = userEvent.setup();
const userDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
streakBonusEnabled: true,
};
const { rerender } = renderEditor({
defaultConfig: DEFAULT_GAME_CONFIG,
});
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={userDefaultConfig}
/>
);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
streakBonusEnabled: true,
}),
}),
expect.any(Object)
);
});
it('should NOT override quiz.config when defaultConfig loads later', async () => {
const user = userEvent.setup();
const quizConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: false,
hostParticipates: false,
};
const userDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
hostParticipates: true,
};
const { rerender } = renderEditor({
quiz: createMockQuiz({ config: quizConfig }),
defaultConfig: DEFAULT_GAME_CONFIG,
});
rerender(
<QuizEditor
quiz={createMockQuiz({ config: quizConfig })}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={userDefaultConfig}
/>
);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: false,
hostParticipates: false,
}),
}),
expect.any(Object)
);
});
it('should only apply defaultConfig once (not on every change)', async () => {
const user = userEvent.setup();
const userDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
};
const updatedDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
shuffleAnswers: true,
};
const { rerender } = renderEditor({
defaultConfig: DEFAULT_GAME_CONFIG,
});
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={userDefaultConfig}
/>
);
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={updatedDefaultConfig}
/>
);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
shuffleAnswers: false,
}),
}),
expect.any(Object)
);
});
it('should NOT apply defaultConfig if it equals DEFAULT_GAME_CONFIG', async () => {
const user = userEvent.setup();
const { rerender } = renderEditor({
defaultConfig: undefined,
});
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={DEFAULT_GAME_CONFIG}
/>
);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: DEFAULT_GAME_CONFIG,
}),
DEFAULT_GAME_CONFIG
);
});
});
describe('User config modifications after async load', () => {
it('should preserve user modifications after defaultConfig loads', async () => {
const user = userEvent.setup();
const userDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
};
const { rerender } = renderEditor({
defaultConfig: DEFAULT_GAME_CONFIG,
});
await user.click(screen.getByText('Game Settings'));
await user.click(screen.getByText('Host Participates'));
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={userDefaultConfig}
/>
);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
hostParticipates: false,
}),
}),
expect.any(Object)
);
});
});
describe('Page refresh simulation', () => {
it('should correctly initialize config after page refresh with quiz.config', async () => {
const user = userEvent.setup();
const savedConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
streakBonusEnabled: true,
hostParticipates: false,
};
renderEditor({
quiz: createMockQuiz({ config: savedConfig }),
defaultConfig: DEFAULT_GAME_CONFIG,
});
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
streakBonusEnabled: true,
hostParticipates: false,
}),
}),
expect.any(Object)
);
});
it('should apply user defaults on refresh when quiz has no config', async () => {
const user = userEvent.setup();
const userDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
penaltyForWrongAnswer: true,
};
renderEditor({
quiz: createMockQuiz(),
defaultConfig: userDefaultConfig,
});
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleAnswers: true,
penaltyForWrongAnswer: true,
}),
}),
expect.any(Object)
);
});
});
describe('Non-happy path scenarios', () => {
it('should handle undefined defaultConfig prop', async () => {
const user = userEvent.setup();
renderEditor({
defaultConfig: undefined,
});
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: DEFAULT_GAME_CONFIG,
}),
DEFAULT_GAME_CONFIG
);
});
it('should handle rapid defaultConfig changes', async () => {
const user = userEvent.setup();
const configs = [
{ ...DEFAULT_GAME_CONFIG, shuffleQuestions: true },
{ ...DEFAULT_GAME_CONFIG, shuffleAnswers: true },
{ ...DEFAULT_GAME_CONFIG, hostParticipates: false },
];
const { rerender } = renderEditor({
defaultConfig: DEFAULT_GAME_CONFIG,
});
for (const config of configs) {
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={config}
/>
);
}
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledTimes(1);
});
it('should apply defaultConfig even when component rerenders', async () => {
const user = userEvent.setup();
const userDefaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
hostParticipates: false,
};
const { rerender } = renderEditor({
defaultConfig: DEFAULT_GAME_CONFIG,
});
rerender(
<QuizEditor
quiz={createMockQuiz()}
onSave={mockOnSave}
onStartGame={mockOnStartGame}
onConfigChange={mockOnConfigChange}
onBack={mockOnBack}
showSaveButton={true}
defaultConfig={userDefaultConfig}
/>
);
await user.click(screen.getByText(/Start Game/));
expect(mockOnStartGame).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
shuffleQuestions: true,
hostParticipates: false,
}),
}),
expect.any(Object)
);
});
});
});
describe('QuizEditor - Config Priority Order', () => {
const mockOnStartGame = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should follow priority: quiz.config > defaultConfig > DEFAULT_GAME_CONFIG', async () => {
const user = userEvent.setup();
const quizConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleQuestions: true,
};
const defaultConfig: GameConfig = {
...DEFAULT_GAME_CONFIG,
shuffleAnswers: true,
};
render(
<QuizEditor
quiz={createMockQuiz({ config: quizConfig })}
onSave={vi.fn()}
onStartGame={mockOnStartGame}
onBack={vi.fn()}
defaultConfig={defaultConfig}
/>
);
await user.click(screen.getByText(/Start Game/));
const calledConfig = mockOnStartGame.mock.calls[0][1];
expect(calledConfig.shuffleQuestions).toBe(true);
expect(calledConfig.shuffleAnswers).toBe(false);
});
});