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
495
tests/components/QuizEditorAsyncConfig.test.tsx
Normal file
495
tests/components/QuizEditorAsyncConfig.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue