Compare commits

...

2 commits

Author SHA1 Message Date
7c03c594c1
Branding 2026-01-15 11:52:29 -07:00
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
23 changed files with 2080 additions and 95 deletions

View file

@ -3,6 +3,7 @@
# ============================================================================== # ==============================================================================
PG_PASS= PG_PASS=
AUTHENTIK_SECRET_KEY= AUTHENTIK_SECRET_KEY=
ENCRYPTION_KEY=
# ============================================================================== # ==============================================================================
# HOST CONFIGURATION # HOST CONFIGURATION

View file

@ -25,6 +25,166 @@ metadata:
blueprints.goauthentik.io/description: "Complete Kaboot OAuth2/OIDC setup with enrollment flow" blueprints.goauthentik.io/description: "Complete Kaboot OAuth2/OIDC setup with enrollment flow"
entries: entries:
# ═══════════════════════════════════════════════════════════════════════════════
# BRANDING
# ═══════════════════════════════════════════════════════════════════════════════
- id: kaboot-brand
model: authentik_brands.brand
identifiers:
domain: localhost
attrs:
domain: localhost
default: true
branding_title: Kaboot
branding_logo: /media/branding/logo.svg
branding_favicon: /media/branding/logo.svg
flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]]
attributes:
settings:
theme:
base: light
css: |
/* Kaboot Theme for Authentik */
:root {
--ak-accent: #2563eb;
--ak-primary: #2563eb;
--ak-primary-dark: #1e40af;
--ak-primary-light: #60a5fa;
--ak-error: #ef4444;
--ak-success: #22c55e;
--pf-global--FontFamily--sans-serif: "Inter", system-ui, -apple-system, sans-serif;
}
/* Background color fallback (image set via Flow settings) */
body {
background-color: #2563eb !important;
}
/* Login Card */
.pf-c-login__main {
background-color: #ffffff !important;
border-radius: 2rem !important;
box-shadow: 0 10px 0 rgba(0,0,0,0.1) !important;
padding: 3rem !important;
max-width: 500px !important;
margin: 0 auto;
}
/* Inputs */
.pf-c-form-control {
border: 2px solid #e5e7eb !important;
border-radius: 1rem !important;
padding: 0.75rem 1rem !important;
font-weight: 600 !important;
color: #333 !important;
font-size: 1rem !important;
box-shadow: none !important;
transition: all 0.2s ease !important;
}
.pf-c-form-control:focus {
border-color: #2563eb !important;
outline: none !important;
}
/* Primary Button - Kaboot Signature 3D Style */
.pf-c-button.pf-m-primary {
background-color: #333333 !important;
color: #ffffff !important;
border: none !important;
border-radius: 1rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 800 !important;
font-size: 1.1rem !important;
box-shadow: 0 6px 0 #000000 !important;
transform: translateY(0) !important;
transition: all 0.1s ease !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
}
.pf-c-button.pf-m-primary:hover {
background-color: #1a1a1a !important;
}
.pf-c-button.pf-m-primary:active {
transform: translateY(6px) !important;
box-shadow: none !important;
}
/* Secondary/Link Buttons */
.pf-c-button.pf-m-secondary, .pf-c-button.pf-m-link {
color: #2563eb !important;
font-weight: 600 !important;
}
/* Titles */
.pf-c-title {
font-weight: 900 !important;
color: #111827 !important;
text-align: center !important;
margin-bottom: 1.5rem !important;
}
/* Logo styling if present in header */
.pf-c-brand {
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
}
/* Alert/Notification boxes */
.pf-c-alert {
border-radius: 1rem !important;
border: 2px solid transparent !important;
box-shadow: 0 4px 0 rgba(0,0,0,0.05) !important;
}
.pf-c-alert.pf-m-danger {
background-color: #fef2f2 !important;
border-color: #fca5a5 !important;
color: #991b1b !important;
}
/* Footer links */
.pf-c-login__main-footer-links-item-link {
color: #6b7280 !important;
font-weight: 500 !important;
}
/* Hide the default background image if any */
.pf-c-background-image {
display: none !important;
}
# ═══════════════════════════════════════════════════════════════════════════════
# FLOW BACKGROUNDS
# ═══════════════════════════════════════════════════════════════════════════════
- id: update-authentication-flow-background
model: authentik_flows.flow
identifiers:
slug: default-authentication-flow
attrs:
title: Welcome to Kaboot!
background: /media/branding/background.svg
- id: update-invalidation-flow-background
model: authentik_flows.flow
identifiers:
slug: default-invalidation-flow
attrs:
background: /media/branding/background.svg
- id: update-authorization-flow-background
model: authentik_flows.flow
identifiers:
slug: default-provider-authorization-implicit-consent
attrs:
background: /media/branding/background.svg
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# GROUPS # GROUPS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -173,6 +333,7 @@ entries:
slug: enrollment-flow slug: enrollment-flow
designation: enrollment designation: enrollment
authentication: none authentication: none
background: /media/branding/background.svg
- id: enrollment-flow-prompt-binding - id: enrollment-flow-prompt-binding
model: authentik_flows.flowstagebinding model: authentik_flows.flowstagebinding

View file

@ -0,0 +1,13 @@
<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#2563eb" />
<stop offset="100%" stop-color="#60a5fa" />
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="url(#bg-gradient)" />
<!-- Subtle decorative elements -->
<circle cx="200" cy="200" r="400" fill="white" fill-opacity="0.03" />
<circle cx="1700" cy="900" r="500" fill="white" fill-opacity="0.03" />
</svg>

After

Width:  |  Height:  |  Size: 573 B

View file

@ -0,0 +1,115 @@
settings:
theme:
base: light
css: |
/* Kaboot Theme for Authentik */
:root {
--ak-accent: #2563eb;
--ak-primary: #2563eb;
--ak-primary-dark: #1e40af;
--ak-primary-light: #60a5fa;
--ak-error: #ef4444;
--ak-success: #22c55e;
--pf-global--FontFamily--sans-serif: "Inter", system-ui, -apple-system, sans-serif;
}
/* Background color fallback (image set via Brand settings) */
body {
background-color: #2563eb !important;
}
/* Login Card */
.pf-c-login__main {
background-color: #ffffff !important;
border-radius: 2rem !important;
box-shadow: 0 10px 0 rgba(0,0,0,0.1) !important;
padding: 3rem !important;
max-width: 500px !important;
margin: 0 auto;
}
/* Inputs */
.pf-c-form-control {
border: 2px solid #e5e7eb !important;
border-radius: 1rem !important;
padding: 0.75rem 1rem !important;
font-weight: 600 !important;
color: #333 !important;
font-size: 1rem !important;
box-shadow: none !important;
transition: all 0.2s ease !important;
}
.pf-c-form-control:focus {
border-color: #2563eb !important;
outline: none !important;
}
/* Primary Button - Kaboot Signature 3D Style */
.pf-c-button.pf-m-primary {
background-color: #333333 !important;
color: #ffffff !important;
border: none !important;
border-radius: 1rem !important;
padding: 0.75rem 1.5rem !important;
font-weight: 800 !important;
font-size: 1.1rem !important;
box-shadow: 0 6px 0 #000000 !important;
transform: translateY(0) !important;
transition: all 0.1s ease !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
}
.pf-c-button.pf-m-primary:hover {
background-color: #1a1a1a !important;
}
.pf-c-button.pf-m-primary:active {
transform: translateY(6px) !important;
box-shadow: none !important;
}
/* Secondary/Link Buttons */
.pf-c-button.pf-m-secondary, .pf-c-button.pf-m-link {
color: #2563eb !important;
font-weight: 600 !important;
}
/* Titles */
.pf-c-title {
font-weight: 900 !important;
color: #111827 !important;
text-align: center !important;
margin-bottom: 1.5rem !important;
}
/* Logo styling if present in header */
.pf-c-brand {
filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
}
/* Alert/Notification boxes */
.pf-c-alert {
border-radius: 1rem !important;
border: 2px solid transparent !important;
box-shadow: 0 4px 0 rgba(0,0,0,0.05) !important;
}
.pf-c-alert.pf-m-danger {
background-color: #fef2f2 !important;
border-color: #fca5a5 !important;
color: #991b1b !important;
}
/* Footer links */
.pf-c-login__main-footer-links-item-link {
color: #6b7280 !important;
font-weight: 500 !important;
}
/* Hide the default background image if any */
.pf-c-background-image {
display: none !important;
}

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
<path d="M9 13a4.5 4.5 0 0 0 3-4" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M12 13h4" />
<path d="M12 18h6a2 2 0 0 1 2 2v1" />
<path d="M12 8h8" />
<path d="M16 8V5a2 2 0 0 1 2-2" />
<circle cx="16" cy="13" r=".5" />
<circle cx="18" cy="3" r=".5" />
<circle cx="20" cy="21" r=".5" />
<circle cx="20" cy="8" r=".5" />
</svg>

After

Width:  |  Height:  |  Size: 730 B

View file

@ -75,7 +75,7 @@ export const AuthButton: React.FC<AuthButtonProps> = ({ onAccountSettingsClick }
<button <button
onClick={() => { onClick={() => {
setDropdownOpen(false); setDropdownOpen(false);
auth.signoutRedirect(); auth.signoutRedirect({ post_logout_redirect_uri: window.location.origin });
}} }}
className="w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 flex items-center gap-2 font-medium transition" className="w-full px-4 py-2 text-left text-gray-700 hover:bg-gray-100 flex items-center gap-2 font-medium transition"
> >

View file

@ -39,11 +39,13 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const setMode = (newMode: 'HOST' | 'JOIN') => { const setMode = (newMode: 'HOST' | 'JOIN') => {
setModeState(newMode); setModeState(newMode);
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams();
const modalParam = searchParams.get('modal');
if (modalParam) {
newParams.set('modal', modalParam);
}
if (newMode === 'HOST') { if (newMode === 'HOST') {
newParams.set('mode', 'host'); newParams.set('mode', 'host');
} else {
newParams.delete('mode');
} }
setSearchParams(newParams, { replace: true }); setSearchParams(newParams, { replace: true });
}; };
@ -58,44 +60,33 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const defaultConfigOpen = modalParam === 'settings'; const defaultConfigOpen = modalParam === 'settings';
const accountSettingsOpen = modalParam === 'account'; const accountSettingsOpen = modalParam === 'account';
const setLibraryOpen = (open: boolean) => { const buildCleanParams = (updates: Record<string, string | null>) => {
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams();
if (open) { const modeParam = searchParams.get('mode');
newParams.set('modal', 'library'); if (modeParam) newParams.set('mode', modeParam);
} else {
newParams.delete('modal'); for (const [key, value] of Object.entries(updates)) {
if (value !== null) {
newParams.set(key, value);
}
} }
setSearchParams(newParams); return newParams;
};
const setLibraryOpen = (open: boolean) => {
setSearchParams(buildCleanParams({ modal: open ? 'library' : null }));
}; };
const setPreferencesOpen = (open: boolean) => { const setPreferencesOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams); setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null }));
if (open) {
newParams.set('modal', 'preferences');
} else {
newParams.delete('modal');
}
setSearchParams(newParams);
}; };
const setDefaultConfigOpen = (open: boolean) => { const setDefaultConfigOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams); setSearchParams(buildCleanParams({ modal: open ? 'settings' : null }));
if (open) {
newParams.set('modal', 'settings');
} else {
newParams.delete('modal');
}
setSearchParams(newParams);
}; };
const setAccountSettingsOpen = (open: boolean) => { const setAccountSettingsOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams); setSearchParams(buildCleanParams({ modal: open ? 'account' : null }));
if (open) {
newParams.set('modal', 'account');
} else {
newParams.delete('modal');
}
setSearchParams(newParams);
}; };
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);

View file

@ -39,11 +39,20 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
const [config, setConfig] = useState<GameConfig>( const [config, setConfig] = useState<GameConfig>(
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
); );
const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config);
useEffect(() => {
if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) {
setConfig(defaultConfig);
setHasAppliedDefaultConfig(true);
}
}, [defaultConfig, hasAppliedDefaultConfig]);
useBodyScrollLock(!!showDeleteConfirm); useBodyScrollLock(!!showDeleteConfirm);
const handleConfigChange = useCallback((newConfig: GameConfig) => { const handleConfigChange = useCallback((newConfig: GameConfig) => {
setConfig(newConfig); setConfig(newConfig);
setHasAppliedDefaultConfig(true);
onConfigChange?.(newConfig); onConfigChange?.(newConfig);
}, [onConfigChange]); }, [onConfigChange]);
@ -191,7 +200,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
{showSaveButton && ( {showSaveButton && (
<button <button
onClick={() => onSave(quiz)} onClick={() => onSave({ ...quiz, config })}
disabled={isSaving} disabled={isSaving}
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50" className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
title="Save to library" title="Save to library"

View file

@ -91,6 +91,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001
DATABASE_PATH: /data/kaboot.db DATABASE_PATH: /data/kaboot.db
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required}
OIDC_ISSUER: ${OIDC_ISSUER} OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_JWKS_URI: ${OIDC_JWKS_URI} OIDC_JWKS_URI: ${OIDC_JWKS_URI}
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/

View file

@ -108,6 +108,7 @@ services:
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001
DATABASE_PATH: /data/kaboot.db DATABASE_PATH: /data/kaboot.db
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?encryption key required}
OIDC_ISSUER: http://${KABOOT_HOST:-localhost}:${AUTHENTIK_PORT_HTTP:-9000}/application/o/kaboot/ OIDC_ISSUER: http://${KABOOT_HOST:-localhost}:${AUTHENTIK_PORT_HTTP:-9000}/application/o/kaboot/
OIDC_JWKS_URI: http://${KABOOT_HOST:-localhost}:${AUTHENTIK_PORT_HTTP:-9000}/application/o/kaboot/jwks/ OIDC_JWKS_URI: http://${KABOOT_HOST:-localhost}:${AUTHENTIK_PORT_HTTP:-9000}/application/o/kaboot/jwks/
OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/ OIDC_INTERNAL_JWKS_URI: http://authentik-server:9000/application/o/kaboot/jwks/

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from 'react-oidc-context';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
import { generateQuiz } from '../services/geminiService'; import { generateQuiz } from '../services/geminiService';
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
@ -8,6 +9,7 @@ import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generato
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const SESSION_STORAGE_KEY = 'kaboot_session'; const SESSION_STORAGE_KEY = 'kaboot_session';
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
const STATE_SYNC_INTERVAL = 5000; const STATE_SYNC_INTERVAL = 5000;
interface StoredSession { interface StoredSession {
@ -35,9 +37,33 @@ const clearStoredSession = () => {
localStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem(SESSION_STORAGE_KEY);
}; };
interface DraftQuiz {
quiz: Quiz;
topic?: string;
sourceQuizId?: string;
}
const getDraftQuiz = (): DraftQuiz | null => {
try {
const stored = sessionStorage.getItem(DRAFT_QUIZ_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
};
const storeDraftQuiz = (draft: DraftQuiz) => {
sessionStorage.setItem(DRAFT_QUIZ_KEY, JSON.stringify(draft));
};
const clearDraftQuiz = () => {
sessionStorage.removeItem(DRAFT_QUIZ_KEY);
};
export const useGame = () => { export const useGame = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const auth = useAuth();
const [role, setRole] = useState<GameRole>('HOST'); const [role, setRole] = useState<GameRole>('HOST');
const [gameState, setGameState] = useState<GameState>('LANDING'); const [gameState, setGameState] = useState<GameState>('LANDING');
@ -95,9 +121,13 @@ export const useGame = () => {
useEffect(() => { useEffect(() => {
if (isInitializingFromUrl.current) return; if (isInitializingFromUrl.current) return;
if (location.pathname === '/callback') return; if (auth.isLoading) return;
const getTargetPath = () => { const getTargetPath = () => {
if (location.pathname === '/callback') {
return '/';
}
switch (gameState) { switch (gameState) {
case 'LANDING': case 'LANDING':
if (gamePin && location.pathname.startsWith('/play/')) { if (gamePin && location.pathname.startsWith('/play/')) {
@ -108,7 +138,7 @@ export const useGame = () => {
case 'GENERATING': case 'GENERATING':
return '/create'; return '/create';
case 'EDITING': case 'EDITING':
return '/edit'; return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
case 'LOBBY': case 'LOBBY':
case 'COUNTDOWN': case 'COUNTDOWN':
case 'QUESTION': case 'QUESTION':
@ -136,7 +166,19 @@ export const useGame = () => {
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState); const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
navigate(targetPath + location.search, { replace: useReplace }); navigate(targetPath + location.search, { replace: useReplace });
} }
}, [gameState, gamePin, role, navigate, location.pathname, location.search]); }, [gameState, gamePin, role, navigate, location.pathname, location.search, sourceQuizId, auth.isLoading]);
useEffect(() => {
if (auth.isLoading) return;
const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(location.pathname) &&
location.pathname !== '/edit/draft';
if (isLibraryQuizRoute && !auth.isAuthenticated) {
clearDraftQuiz();
auth.signinRedirect();
}
}, [auth.isLoading, auth.isAuthenticated, location.pathname]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + ""; const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
@ -486,9 +528,36 @@ export const useGame = () => {
return; return;
} }
if (path === '/edit') { if (path === '/edit/draft') {
const session = getStoredSession(); const draft = getDraftQuiz();
if (!session) { if (draft) {
isInitializingFromUrl.current = true;
setRole('HOST');
setQuiz(draft.quiz);
setSourceQuizId(null);
if (draft.topic !== undefined) {
setPendingQuizToSave({ quiz: draft.quiz, topic: draft.topic });
}
setGameState('EDITING');
isInitializingFromUrl.current = false;
} else {
navigate('/', { replace: true });
}
return;
}
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
if (editMatch) {
const quizId = editMatch[1];
const draft = getDraftQuiz();
if (draft && draft.sourceQuizId === quizId) {
isInitializingFromUrl.current = true;
setRole('HOST');
setQuiz(draft.quiz);
setSourceQuizId(quizId);
setGameState('EDITING');
isInitializingFromUrl.current = false;
} else {
navigate('/', { replace: true }); navigate('/', { replace: true });
} }
return; return;
@ -554,6 +623,7 @@ export const useGame = () => {
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || ''; const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel }); setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
setQuiz(generatedQuiz); setQuiz(generatedQuiz);
storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel });
setGameState('EDITING'); setGameState('EDITING');
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : "Failed to generate quiz."; const message = e instanceof Error ? e.message : "Failed to generate quiz.";
@ -586,17 +656,25 @@ export const useGame = () => {
setRole('HOST'); setRole('HOST');
setQuiz(savedQuiz); setQuiz(savedQuiz);
setSourceQuizId(quizId || null); setSourceQuizId(quizId || null);
storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId });
setGameState('EDITING'); setGameState('EDITING');
}; };
const updateQuizFromEditor = (updatedQuiz: Quiz) => { const updateQuizFromEditor = (updatedQuiz: Quiz) => {
setQuiz(updatedQuiz); setQuiz(updatedQuiz);
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' }); setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
const currentDraft = getDraftQuiz();
storeDraftQuiz({
quiz: updatedQuiz,
topic: currentDraft?.topic,
sourceQuizId: currentDraft?.sourceQuizId
});
}; };
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => { const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
setQuiz(finalQuiz); setQuiz(finalQuiz);
setGameConfig(config); setGameConfig(config);
clearDraftQuiz();
initializeHostGame(finalQuiz, config.hostParticipates); initializeHostGame(finalQuiz, config.hostParticipates);
}; };
@ -604,6 +682,7 @@ export const useGame = () => {
setQuiz(null); setQuiz(null);
setPendingQuizToSave(null); setPendingQuizToSave(null);
setSourceQuizId(null); setSourceQuizId(null);
clearDraftQuiz();
setGameState('LANDING'); setGameState('LANDING');
}; };

View file

@ -71,8 +71,12 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
: 'Failed to load quiz.'; : 'Failed to load quiz.';
throw new Error(errorText); throw new Error(errorText);
} }
const data = await response.json();
toast.success('Quiz loaded!'); toast.success('Quiz loaded!');
return response.json(); return {
...data,
config: data.gameConfig,
};
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load quiz'; const message = err instanceof Error ? err.message : 'Failed to load quiz';
if (!message.includes('redirecting')) { if (!message.includes('redirecting')) {

View file

@ -46,6 +46,7 @@ PG_PASS=$(openssl rand -base64 36 | tr -d '\n')
AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n') AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')
AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 24 | tr -d '\n') AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')
AUTHENTIK_BOOTSTRAP_TOKEN=$(openssl rand -base64 36 | tr -d '\n') AUTHENTIK_BOOTSTRAP_TOKEN=$(openssl rand -base64 36 | tr -d '\n')
ENCRYPTION_KEY=$(openssl rand -base64 36 | tr -d '\n')
cp "$ENV_EXAMPLE" "$ENV_FILE" cp "$ENV_EXAMPLE" "$ENV_FILE"
@ -54,12 +55,14 @@ if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE" sed -i '' "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE"
sed -i '' "s|^AUTHENTIK_BOOTSTRAP_PASSWORD=.*|AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_BOOTSTRAP_PASSWORD}|" "$ENV_FILE" sed -i '' "s|^AUTHENTIK_BOOTSTRAP_PASSWORD=.*|AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_BOOTSTRAP_PASSWORD}|" "$ENV_FILE"
sed -i '' "s|^AUTHENTIK_BOOTSTRAP_TOKEN=.*|AUTHENTIK_BOOTSTRAP_TOKEN=${AUTHENTIK_BOOTSTRAP_TOKEN}|" "$ENV_FILE" sed -i '' "s|^AUTHENTIK_BOOTSTRAP_TOKEN=.*|AUTHENTIK_BOOTSTRAP_TOKEN=${AUTHENTIK_BOOTSTRAP_TOKEN}|" "$ENV_FILE"
sed -i '' "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|" "$ENV_FILE"
sed -i '' "s|^KABOOT_HOST=.*|KABOOT_HOST=${KABOOT_HOST}|" "$ENV_FILE" sed -i '' "s|^KABOOT_HOST=.*|KABOOT_HOST=${KABOOT_HOST}|" "$ENV_FILE"
else else
sed -i "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE" sed -i "s|^PG_PASS=.*|PG_PASS=${PG_PASS}|" "$ENV_FILE"
sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE" sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}|" "$ENV_FILE"
sed -i "s|^AUTHENTIK_BOOTSTRAP_PASSWORD=.*|AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_BOOTSTRAP_PASSWORD}|" "$ENV_FILE" sed -i "s|^AUTHENTIK_BOOTSTRAP_PASSWORD=.*|AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_BOOTSTRAP_PASSWORD}|" "$ENV_FILE"
sed -i "s|^AUTHENTIK_BOOTSTRAP_TOKEN=.*|AUTHENTIK_BOOTSTRAP_TOKEN=${AUTHENTIK_BOOTSTRAP_TOKEN}|" "$ENV_FILE" sed -i "s|^AUTHENTIK_BOOTSTRAP_TOKEN=.*|AUTHENTIK_BOOTSTRAP_TOKEN=${AUTHENTIK_BOOTSTRAP_TOKEN}|" "$ENV_FILE"
sed -i "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=${ENCRYPTION_KEY}|" "$ENV_FILE"
sed -i "s|^KABOOT_HOST=.*|KABOOT_HOST=${KABOOT_HOST}|" "$ENV_FILE" sed -i "s|^KABOOT_HOST=.*|KABOOT_HOST=${KABOOT_HOST}|" "$ENV_FILE"
fi fi

View file

@ -11,6 +11,8 @@
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
@ -21,6 +23,7 @@
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-rate-limit": "^5.1.3",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
@ -778,6 +781,16 @@
"@types/serve-static": "^2" "@types/serve-static": "^2"
} }
}, },
"node_modules/@types/express-rate-limit": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz",
"integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express-serve-static-core": { "node_modules/@types/express-serve-static-core": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
@ -1396,6 +1409,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@ -1437,6 +1451,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/file-type": { "node_modules/file-type": {
"version": "16.5.4", "version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
@ -1618,6 +1650,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -1688,6 +1729,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View file

@ -14,6 +14,8 @@
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
@ -24,6 +26,7 @@
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-rate-limit": "^5.1.3",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

View file

@ -1,5 +1,7 @@
import express, { Request, Response, NextFunction } from 'express'; import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { db } from './db/connection.js'; import { db } from './db/connection.js';
import quizzesRouter from './routes/quizzes.js'; import quizzesRouter from './routes/quizzes.js';
import usersRouter from './routes/users.js'; import usersRouter from './routes/users.js';
@ -9,12 +11,38 @@ import gamesRouter from './routes/games.js';
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
}));
const isDev = process.env.NODE_ENV !== 'production';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: isDev ? 1000 : 500,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
skip: (req) => req.path === '/health',
});
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim()); const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim());
app.use(cors({ app.use(cors({
origin: corsOrigins.length === 1 ? corsOrigins[0] : corsOrigins, origin: corsOrigins.length === 1 ? corsOrigins[0] : corsOrigins,
credentials: true, credentials: true,
})); }));
app.use(apiLimiter);
const LOG_REQUESTS = process.env.LOG_REQUESTS === 'true'; const LOG_REQUESTS = process.env.LOG_REQUESTS === 'true';
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {

View file

@ -1,9 +1,19 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import rateLimit from 'express-rate-limit';
import { db } from '../db/connection.js'; import { db } from '../db/connection.js';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
const router = Router(); const router = Router();
const isDev = process.env.NODE_ENV !== 'production';
const gameCreationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: isDev ? 100 : 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many game creations, please try again later.' },
});
const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10); const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10);
interface GameSession { interface GameSession {
@ -34,7 +44,7 @@ const cleanupExpiredSessions = () => {
setInterval(cleanupExpiredSessions, 60 * 1000); setInterval(cleanupExpiredSessions, 60 * 1000);
router.post('/', (req: Request, res: Response) => { router.post('/', gameCreationLimiter, (req: Request, res: Response) => {
try { try {
const { pin, hostPeerId, quiz, gameConfig } = req.body; const { pin, hostPeerId, quiz, gameConfig } = req.body;

View file

@ -1,9 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import { processDocument, SUPPORTED_TYPES } from '../services/documentParser.js'; import { processDocument, SUPPORTED_TYPES } from '../services/documentParser.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router(); const router = Router();
router.use(requireAuth);
const storage = multer.memoryStorage(); const storage = multer.memoryStorage();
const upload = multer({ const upload = multer({

View file

@ -1,26 +1,55 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { db } from '../db/connection.js'; import { db } from '../db/connection.js';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
import { encryptForUser, decryptForUser, encryptJsonForUser, decryptJsonForUser } from '../services/encryption.js';
const router = Router(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
interface UserRow {
id: string;
username: string;
email: string | null;
displayName: string | null;
defaultGameConfig: string | null;
colorScheme: string | null;
geminiApiKey: string | null;
createdAt: string | null;
lastLogin: string | null;
}
function decryptUserData(row: UserRow, userSub: string) {
return {
id: row.id,
username: row.username,
email: decryptForUser(row.email, userSub),
displayName: decryptForUser(row.displayName, userSub),
defaultGameConfig: decryptJsonForUser(row.defaultGameConfig, userSub),
colorScheme: row.colorScheme,
geminiApiKey: decryptForUser(row.geminiApiKey, userSub),
createdAt: row.createdAt,
lastLogin: row.lastLogin,
};
}
router.get('/me', (req: AuthenticatedRequest, res: Response) => { router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(` const userSub = req.user!.sub;
const row = db.prepare(`
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
color_scheme as colorScheme, gemini_api_key as geminiApiKey, color_scheme as colorScheme, gemini_api_key as geminiApiKey,
created_at as createdAt, last_login as lastLogin created_at as createdAt, last_login as lastLogin
FROM users FROM users
WHERE id = ? WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined; `).get(userSub) as UserRow | undefined;
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access'); const hasAIAccess = groups.includes('kaboot-ai-access');
if (!user) { if (!row) {
res.json({ res.json({
id: req.user!.sub, id: userSub,
username: req.user!.preferred_username, username: req.user!.preferred_username,
email: req.user!.email, email: req.user!.email,
displayName: req.user!.name, displayName: req.user!.name,
@ -35,26 +64,23 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
return; return;
} }
let parsedConfig = null; const decrypted = decryptUserData(row, userSub);
if (user.defaultGameConfig && typeof user.defaultGameConfig === 'string') {
try {
parsedConfig = JSON.parse(user.defaultGameConfig);
} catch {
parsedConfig = null;
}
}
res.json({ res.json({
...user, ...decrypted,
defaultGameConfig: parsedConfig,
hasAIAccess, hasAIAccess,
isNew: false isNew: false
}); });
}); });
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => { router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const { defaultGameConfig } = req.body; const { defaultGameConfig } = req.body;
const encryptedConfig = encryptJsonForUser(defaultGameConfig, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const upsertUser = db.prepare(` const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, default_game_config, last_login) INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@ -63,40 +89,45 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
last_login = CURRENT_TIMESTAMP last_login = CURRENT_TIMESTAMP
`); `);
const configJson = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
upsertUser.run( upsertUser.run(
req.user!.sub, userSub,
req.user!.preferred_username, req.user!.preferred_username,
req.user!.email || null, encryptedEmail,
req.user!.name || null, encryptedDisplayName,
configJson, encryptedConfig,
configJson encryptedConfig
); );
res.json({ success: true }); res.json({ success: true });
}); });
router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => { router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const user = db.prepare(` const user = db.prepare(`
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey
FROM users FROM users
WHERE id = ? WHERE id = ?
`).get(req.user!.sub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined; `).get(userSub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access'); const hasAIAccess = groups.includes('kaboot-ai-access');
res.json({ res.json({
colorScheme: user?.colorScheme || 'blue', colorScheme: user?.colorScheme || 'blue',
geminiApiKey: user?.geminiApiKey || null, geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
hasAIAccess, hasAIAccess,
}); });
}); });
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub;
const { colorScheme, geminiApiKey } = req.body; const { colorScheme, geminiApiKey } = req.body;
const encryptedApiKey = encryptForUser(geminiApiKey || null, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const upsertUser = db.prepare(` const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login) INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
@ -107,14 +138,14 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
`); `);
upsertUser.run( upsertUser.run(
req.user!.sub, userSub,
req.user!.preferred_username, req.user!.preferred_username,
req.user!.email || null, encryptedEmail,
req.user!.name || null, encryptedDisplayName,
colorScheme || 'blue', colorScheme || 'blue',
geminiApiKey || null, encryptedApiKey,
colorScheme || 'blue', colorScheme || 'blue',
geminiApiKey || null encryptedApiKey
); );
res.json({ success: true }); res.json({ success: true });

View file

@ -0,0 +1,123 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 16;
let encryptionWarningShown = false;
function getMasterKey(): Buffer | null {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
if (!encryptionWarningShown) {
console.warn('[SECURITY WARNING] ENCRYPTION_KEY not set. User data will NOT be encrypted.');
console.warn('Generate one with: openssl rand -base64 36');
encryptionWarningShown = true;
}
return null;
}
return createHash('sha256').update(key).digest();
}
function deriveUserKey(userSub: string, salt: Buffer): Buffer | null {
const masterKey = getMasterKey();
if (!masterKey) return null;
return Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
}
export function encryptForUser(plaintext: string | null | undefined, userSub: string): string | null {
if (plaintext === null || plaintext === undefined || plaintext === '') {
return null;
}
const salt = randomBytes(SALT_LENGTH);
const key = deriveUserKey(userSub, salt);
if (!key) {
return plaintext;
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
const combined = Buffer.concat([salt, iv, authTag, encrypted]);
return combined.toString('base64');
}
export function decryptForUser(ciphertext: string | null | undefined, userSub: string): string | null {
if (ciphertext === null || ciphertext === undefined || ciphertext === '') {
return null;
}
const masterKey = getMasterKey();
if (!masterKey) {
return ciphertext;
}
try {
const combined = Buffer.from(ciphertext, 'base64');
if (combined.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1) {
return ciphertext;
}
const salt = combined.subarray(0, SALT_LENGTH);
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
const key = Buffer.from(
hkdfSync('sha256', masterKey, salt, `kaboot-user-data:${userSub}`, 32)
);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString('utf8');
} catch {
return ciphertext;
}
}
export function isEncrypted(value: string | null | undefined): boolean {
if (!value) return false;
try {
const decoded = Buffer.from(value, 'base64');
return decoded.length >= SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1;
} catch {
return false;
}
}
export function encryptJsonForUser(data: object | null | undefined, userSub: string): string | null {
if (data === null || data === undefined) {
return null;
}
return encryptForUser(JSON.stringify(data), userSub);
}
export function decryptJsonForUser<T>(ciphertext: string | null | undefined, userSub: string): T | null {
const plaintext = decryptForUser(ciphertext, userSub);
if (!plaintext) return null;
try {
return JSON.parse(plaintext) as T;
} catch {
return null;
}
}

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);
});
});

View file

@ -0,0 +1,782 @@
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);
});
});
});

View file

@ -6,7 +6,8 @@ describe('URL Routing and Navigation', () => {
gameState: string, gameState: string,
gamePin: string | null, gamePin: string | null,
role: 'HOST' | 'CLIENT', role: 'HOST' | 'CLIENT',
currentPath: string currentPath: string,
sourceQuizId: string | null = null
): string => { ): string => {
switch (gameState) { switch (gameState) {
case 'LANDING': case 'LANDING':
@ -18,7 +19,7 @@ describe('URL Routing and Navigation', () => {
case 'GENERATING': case 'GENERATING':
return '/create'; return '/create';
case 'EDITING': case 'EDITING':
return '/edit'; return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
case 'LOBBY': case 'LOBBY':
case 'COUNTDOWN': case 'COUNTDOWN':
case 'QUESTION': case 'QUESTION':
@ -66,8 +67,17 @@ describe('URL Routing and Navigation', () => {
}); });
describe('EDITING state', () => { describe('EDITING state', () => {
it('should return "/edit" for EDITING state', () => { it('should return "/edit/draft" for EDITING state without sourceQuizId', () => {
expect(getTargetPath('EDITING', null, 'HOST', '/')).toBe('/edit'); 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 = ( const simulateUrlInit = (
path: string, path: string,
storedSession: StoredSession | null storedSession: StoredSession | null
): { action: string; gamePin?: string; shouldReconnect?: boolean } => { ): { action: string; gamePin?: string; quizId?: string; shouldReconnect?: boolean } => {
const hostMatch = path.match(/^\/host\/(\d+)$/); const hostMatch = path.match(/^\/host\/(\d+)$/);
const playMatch = path.match(/^\/play\/(\d+)$/); const playMatch = path.match(/^\/play\/(\d+)$/);
@ -152,11 +162,14 @@ describe('URL Routing and Navigation', () => {
return { action: 'startCreating' }; return { action: 'startCreating' };
} }
if (path === '/edit') { if (path === '/edit/draft') {
if (!storedSession) { return { action: 'restoreDraft' };
return { action: 'navigateHome' }; }
}
return { action: 'continueEditing' }; const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
if (editMatch) {
const quizId = editMatch[1];
return { action: 'restoreLibraryQuiz', quizId };
} }
if (storedSession) { if (storedSession) {
@ -230,16 +243,35 @@ describe('URL Routing and Navigation', () => {
}); });
}); });
describe('/edit URL', () => { describe('/edit/draft URL', () => {
it('should navigate home when no session', () => { it('should restore draft for AI-generated quizzes', () => {
const result = simulateUrlInit('/edit', null); const result = simulateUrlInit('/edit/draft', null);
expect(result.action).toBe('navigateHome'); 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 session: StoredSession = { pin: '123456', role: 'HOST', hostSecret: 'secret' };
const result = simulateUrlInit('/edit', session); const result = simulateUrlInit('/edit/draft', session);
expect(result.action).toBe('continueEditing'); 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'); expect(gamePin).toBe('123456');
}); });
it('should redirect to home when accessing /edit without any session', () => { it('should redirect to home when accessing /edit/draft without draft in storage', () => {
const path = '/edit'; const path = '/edit/draft';
const session = null; const hasDraftInStorage = false;
let action = 'none'; let action = 'none';
if (path === '/edit') { if (path === '/edit/draft') {
if (!session) { 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'; action = 'navigateHome';
} }
} }
@ -1012,7 +1063,8 @@ describe('OAuth Callback Handling', () => {
it('should NOT skip URL initialization on other paths', () => { it('should NOT skip URL initialization on other paths', () => {
expect(shouldSkipUrlInit('/')).toBe(false); expect(shouldSkipUrlInit('/')).toBe(false);
expect(shouldSkipUrlInit('/create')).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('/host/123456')).toBe(false);
expect(shouldSkipUrlInit('/play/123456')).toBe(false); expect(shouldSkipUrlInit('/play/123456')).toBe(false);
}); });
@ -1031,12 +1083,12 @@ describe('OAuth Callback Handling', () => {
expect(urlInitRan).toBe(true); 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; let urlSyncRan = false;
const pathname = '/callback'; const authIsLoading = true;
const syncUrl = () => { const syncUrl = () => {
if (pathname === '/callback') return; if (authIsLoading) return;
urlSyncRan = true; urlSyncRan = true;
}; };
@ -1044,6 +1096,21 @@ describe('OAuth Callback Handling', () => {
expect(urlSyncRan).toBe(false); 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', () => { describe('localStorage preservation during OAuth', () => {