From 4935251125176c92257ec1ddde331193c717c722 Mon Sep 17 00:00:00 2001 From: LaurensBartelds <48316768+LaurensBartelds@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:28:01 +0200 Subject: [PATCH 1/3] feat: implement retake exam functionality and update translations for exam status --- frontend/src/components/MyResults.tsx | 29 ++++++++++++++++++++++++++- frontend/src/components/Workspace.tsx | 2 +- frontend/src/i18n/en.json | 1 + frontend/src/i18n/nl.json | 1 + 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/MyResults.tsx b/frontend/src/components/MyResults.tsx index ff0b6d7..2bd085f 100644 --- a/frontend/src/components/MyResults.tsx +++ b/frontend/src/components/MyResults.tsx @@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import * as resultApi from '../api/resultApi' +import * as examApi from '../api/examApi' import { getExamUrlId } from '../utils/slug' +import type { Exam } from '../types' interface ResultItem { id: number @@ -17,7 +19,11 @@ interface ResultItem { exam_id: number | null } -export default function MyResults() { +interface Props { + onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void +} + +export default function MyResults({ onLoadExam }: Props) { const { t } = useTranslation() const navigate = useNavigate() const [results, setResults] = useState([]) @@ -25,6 +31,7 @@ export default function MyResults() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null) + const [retakingId, setRetakingId] = useState(null) const loadResults = useCallback(async () => { setLoading(true) @@ -54,6 +61,18 @@ export default function MyResults() { } } + const retakeExam = async (item: ResultItem) => { + if (!item.exam_id) return + setRetakingId(item.id) + try { + const exam = await examApi.getExam(item.exam_id) + onLoadExam(exam, false, item.exam_id) + } catch (e) { + setError((e as Error).message) + setRetakingId(null) + } + } + const reviewResult = async (item: ResultItem) => { try { const data = await resultApi.getResult(item.id) @@ -129,6 +148,14 @@ export default function MyResults() {
{pct}%
+ diff --git a/frontend/src/components/Workspace.tsx b/frontend/src/components/Workspace.tsx index f9b0b77..551e5da 100644 --- a/frontend/src/components/Workspace.tsx +++ b/frontend/src/components/Workspace.tsx @@ -83,7 +83,7 @@ export default function Workspace({ onLoadExam }: Props) {
{activeTab === 'exams' && } - {activeTab === 'results' && } + {activeTab === 'results' && } {activeTab === 'settings' && }
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index b455018..0f94598 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -124,6 +124,7 @@ "explanation": "Explanation", "rubric": "Rubric (self-grade)", "retakeExam": "Retake exam", + "cantRetake": "Exam no longer saved in workspace", "backToHome": "Back to home" }, "tweaks": { diff --git a/frontend/src/i18n/nl.json b/frontend/src/i18n/nl.json index d0c514b..f7a724b 100644 --- a/frontend/src/i18n/nl.json +++ b/frontend/src/i18n/nl.json @@ -124,6 +124,7 @@ "explanation": "Uitleg", "rubric": "Rubric (zelfbeoordeling)", "retakeExam": "Examen opnieuw maken", + "cantRetake": "Examen niet meer opgeslagen in werkruimte", "backToHome": "Terug naar home" }, "tweaks": { -- 2.54.0 From 1b5fd0404021f1c0edc2cfc8c4dbbf4fbdcd80bf Mon Sep 17 00:00:00 2001 From: LaurensBartelds <48316768+LaurensBartelds@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:52:57 +0200 Subject: [PATCH 2/3] feat: enhance security with token blacklisting and input sanitization; add helmet for security headers --- .gitea/workflows/deploy.yml | 4 +- backend/package-lock.json | 10 ++ backend/package.json | 1 + backend/src/config.ts | 2 +- backend/src/index.ts | 19 +++- backend/src/middleware/auth.ts | 10 +- backend/src/middleware/error.ts | 16 +++- backend/src/repositories/userRepo.ts | 8 +- backend/src/routes/auth.ts | 19 ++++ backend/src/routes/exams.ts | 5 +- backend/src/routes/setup.ts | 96 +++++++++++-------- backend/src/services/authService.ts | 26 +++-- backend/src/utils/sanitizeExam.ts | 30 ++++++ backend/src/utils/tokenBlacklist.ts | 46 +++++++++ backend/src/utils/tokens.ts | 4 + backend/src/validators/adminSchemas.test.ts | 4 +- backend/src/validators/adminSchemas.ts | 20 +++- backend/src/validators/authSchemas.test.ts | 10 +- backend/src/validators/authSchemas.ts | 14 ++- backend/src/validators/setupSchemas.test.ts | 4 +- backend/src/validators/setupSchemas.ts | 6 +- node_modules/.package-lock.json | 6 -- .../results.json | 2 +- package-lock.json | 17 +++- package.json | 5 + 25 files changed, 294 insertions(+), 90 deletions(-) create mode 100644 backend/src/utils/sanitizeExam.ts create mode 100644 backend/src/utils/tokenBlacklist.ts delete mode 100644 node_modules/.package-lock.json create mode 100644 package.json diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f1f6d30..fa28641 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -27,10 +27,8 @@ jobs: echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" - name: Write Inventory - # StrictHostKeyChecking=no is intentional for a known home-network host. - # To harden, add the host fingerprint as a secret and use a known_hosts file. run: | - printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \ + printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \ "${{ secrets.ANSIBLE_HOST }}" \ "${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini diff --git a/backend/package-lock.json b/backend/package-lock.json index 276f0bf..50619bf 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.7", "express": "^5.1.0", "express-rate-limit": "^7.5.0", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.3", "zod": "^3.24.2" @@ -1862,6 +1863,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index c23e669..615d86b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "dotenv": "^16.4.7", "express": "^5.1.0", "express-rate-limit": "^7.5.0", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "nodemailer": "^7.0.3", "zod": "^3.24.2" diff --git a/backend/src/config.ts b/backend/src/config.ts index c08c77c..5326cec 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -28,7 +28,7 @@ export const config = { jwtSecret: env('JWT_SECRET'), dbPath: env('DB_PATH', './db/brainwave.db'), corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'), - bodyLimit: env('BODY_LIMIT', '10mb'), + bodyLimit: env('BODY_LIMIT', '100kb'), email: { smtpHost: env('SMTP_HOST', ''), diff --git a/backend/src/index.ts b/backend/src/index.ts index dfb8d0e..4fa1407 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,8 @@ import 'dotenv/config' import express from 'express' import cors from 'cors' +import helmet from 'helmet' +import rateLimit from 'express-rate-limit' import { getDb, closeDb } from './db/database.js' import authRoutes from './routes/auth.js' import examRoutes from './routes/exams.js' @@ -20,9 +22,25 @@ const PORT = config.port const app = express() +// Security headers +app.use(helmet()) + +// CORS app.use(cors({ origin: config.corsOrigin })) + +// Body parsing with reasonable limit app.use(express.json({ limit: config.bodyLimit })) +// Global API rate limiting +const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 200, + message: { error: 'Too many requests, please try again later' }, + standardHeaders: true, + legacyHeaders: false, +}) +app.use('/api/', globalLimiter) + // Request logging app.use((req, res, next) => { const start = Date.now() @@ -63,7 +81,6 @@ app.get('/api/health', (_req, res) => { res.status(overall === 'healthy' ? 200 : 503).json({ status: overall, timestamp: Date.now(), - version: process.env['npm_package_version'] ?? '0.0.1', checks, }) }) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index baabbca..8e4d0d6 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,6 @@ import type { Request, Response, NextFunction } from 'express' -import { verifyToken, type JwtPayload } from '../utils/tokens.js' +import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js' +import { isTokenBlacklisted } from '../utils/tokenBlacklist.js' declare global { namespace Express { @@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo try { const token = header.slice(7) req.user = verifyToken(token) + + // Check if the token has been blacklisted (e.g., after logout) + if (isTokenBlacklisted(hashToken(token))) { + res.status(401).json({ error: 'Token has been revoked' }) + return + } + next() } catch { res.status(401).json({ error: 'Invalid or expired token' }) diff --git a/backend/src/middleware/error.ts b/backend/src/middleware/error.ts index 4e0a925..de4226b 100644 --- a/backend/src/middleware/error.ts +++ b/backend/src/middleware/error.ts @@ -17,15 +17,21 @@ export function errorHandler( _next: any ) { const statusCode = err instanceof AppError ? err.statusCode : 500; - const message = err.message || 'Internal Server Error'; + const isOperational = err instanceof AppError && err.isOperational; - console.error(`[Error] ${statusCode} - ${message}`); - if (!(err instanceof AppError) || !err.isOperational) { + // Log full details server-side + console.error(`[Error] ${statusCode} - ${err.message}`); + if (!isOperational) { console.error(err); } + // Never leak internal error details to the client for unexpected 500s + const clientMessage = statusCode === 500 && !isOperational + ? 'Internal Server Error' + : (err.message || 'Internal Server Error'); + res.status(statusCode).json({ status: 'error', - message, + message: clientMessage, }); -} \ No newline at end of file +} diff --git a/backend/src/repositories/userRepo.ts b/backend/src/repositories/userRepo.ts index f77f135..1ffd477 100644 --- a/backend/src/repositories/userRepo.ts +++ b/backend/src/repositories/userRepo.ts @@ -63,14 +63,14 @@ export function getUserById(id: number): DbUser | undefined { return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined } -export function getUserByVerifyToken(token: string): DbUser | undefined { +export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined { const db = getDb() - return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined + return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined } -export function getUserByResetToken(token: string): DbUser | undefined { +export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined { const db = getDb() - return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined + return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined } export function updateUser(id: number, data: UpdateUserData): boolean { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 785d919..15c1e8b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js' import { asyncHandler } from '../utils/asyncHandler.js' import * as authService from '../services/authService.js' import * as userRepo from '../repositories/userRepo.js' +import { hashToken } from '../utils/tokens.js' +import { blacklistToken } from '../utils/tokenBlacklist.js' import { registerSchema, loginSchema, @@ -113,6 +115,23 @@ router.post( }) ) +// POST /api/auth/logout +router.post( + '/logout', + requireAuth, + asyncHandler(async (req: Request, res) => { + const header = req.headers.authorization + if (header?.startsWith('Bearer ')) { + const token = header.slice(7) + const tokenHash = hashToken(token) + // Blacklist the token so it cannot be reused after logout + // Expiry is 24h from now (matching JWT expiry) + blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000) + } + res.json({ success: true }) + }) +) + // DELETE /api/auth/me router.delete( '/me', diff --git a/backend/src/routes/exams.ts b/backend/src/routes/exams.ts index 1a5d439..307775e 100644 --- a/backend/src/routes/exams.ts +++ b/backend/src/routes/exams.ts @@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js' import { AppError } from '../middleware/error.js' import * as examRepo from '../repositories/examRepo.js' import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js' +import { sanitizeExam } from '../utils/sanitizeExam.js' import type { Request } from 'express' const router = Router() @@ -41,7 +42,7 @@ router.post( '/', validateBody(saveExamSchema), asyncHandler(async (req: Request, res) => { - const exam = req.body.exam + const exam = sanitizeExam(req.body.exam) as typeof req.body.exam const { id } = examRepo.createExam( req.user!.userId, exam.title, @@ -59,7 +60,7 @@ router.put( validateBody(updateExamSchema), asyncHandler(async (req: Request, res) => { const id = Number(req.params.id) - const exam = req.body.exam + const exam = sanitizeExam(req.body.exam) as typeof req.body.exam const updated = examRepo.updateExam( id, req.user!.userId, diff --git a/backend/src/routes/setup.ts b/backend/src/routes/setup.ts index 1f04afb..07a5adb 100644 --- a/backend/src/routes/setup.ts +++ b/backend/src/routes/setup.ts @@ -3,6 +3,7 @@ import bcrypt from 'bcrypt' import { validateBody } from '../middleware/validate.js' import { asyncHandler } from '../utils/asyncHandler.js' import { AppError } from '../middleware/error.js' +import { getDb } from '../db/database.js' import * as userRepo from '../repositories/userRepo.js' import * as settingsRepo from '../repositories/settingsRepo.js' import { setupSchema } from '../validators/setupSchemas.js' @@ -33,54 +34,65 @@ router.post( '/', validateBody(setupSchema), asyncHandler(async (req, res) => { - if (getSiteTitle()) { - throw new AppError(400, 'Setup already completed') - } + const db = getDb() - if (userRepo.countUsers() > 0) { - throw new AppError(400, 'Users already exist') - } + // Atomic transaction prevents race conditions between concurrent setup requests + const tx = db.transaction(() => { + const title = settingsRepo.getSetting('site_title') + if (title) { + throw new AppError(400, 'Setup already completed') + } - const { - email, - password, - displayName, - siteTitle, - language, - smtpHost, - smtpPort, - smtpUser, - smtpPass, - smtpFrom, - smtpSecure, - appUrl, - } = req.body + const userCount = userRepo.countUsers() + if (userCount > 0) { + throw new AppError(400, 'Users already exist') + } - const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) + const { + email, + password, + displayName, + siteTitle, + language, + smtpHost, + smtpPort, + smtpUser, + smtpPass, + smtpFrom, + smtpSecure, + appUrl, + } = req.body - const { id } = userRepo.createUser({ - email, - passwordHash, - displayName, - role: 'admin', - emailVerified: 1, + const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS) + + const { id } = userRepo.createUser({ + email, + passwordHash, + displayName, + role: 'admin', + emailVerified: 1, + }) + + const settings: Array<[string, string]> = [ + ['site_title', siteTitle?.trim() || 'BrainWave'], + ['site_language', language || 'en'], + ['maintenance_mode', 'false'], + ] + + if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost]) + if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort]) + if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser]) + if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass]) + if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom]) + if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure]) + if (appUrl !== undefined) settings.push(['app_url', appUrl]) + + settingsRepo.setSettingsBatch(settings) + + return { id, email, displayName } }) - const settings: Array<[string, string]> = [ - ['site_title', siteTitle?.trim() || 'BrainWave'], - ['site_language', language || 'en'], - ['maintenance_mode', 'false'], - ] - - if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost]) - if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort]) - if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser]) - if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass]) - if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom]) - if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure]) - if (appUrl !== undefined) settings.push(['app_url', appUrl]) - - settingsRepo.setSettingsBatch(settings) + const { id, email, displayName } = tx() const token = signToken({ userId: id, diff --git a/backend/src/services/authService.ts b/backend/src/services/authService.ts index 797d2dc..c97c344 100644 --- a/backend/src/services/authService.ts +++ b/backend/src/services/authService.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcrypt' import * as userRepo from '../repositories/userRepo.js' -import { signToken, generateVerificationToken } from '../utils/tokens.js' +import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js' import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js' import { AppError } from '../middleware/error.js' import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js' @@ -22,7 +22,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS) const role = isFirstUser ? 'admin' : 'user' - const verifyToken = generateVerificationToken() + const rawVerifyToken = generateVerificationToken() + const verifyTokenHash = hashToken(rawVerifyToken) const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS const { id } = userRepo.createUser({ @@ -31,14 +32,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom displayName: input.displayName, role, emailVerified: 0, - verifyToken, + verifyToken: verifyTokenHash, verifyExpires, }) const user = userRepo.getUserById(id)! // Don't await — fire and forget so registration isn't blocked by email failures - sendVerificationEmail(user.email, verifyToken).catch(() => {}) + sendVerificationEmail(user.email, rawVerifyToken).catch(() => {}) return { token: signToken({ @@ -67,6 +68,10 @@ export async function login(input: LoginInput): Promise { throw new AppError(401, 'Invalid email or password') } + if (!user.email_verified) { + throw new AppError(403, 'Please verify your email before logging in') + } + return { token: signToken({ userId: user.id, @@ -118,7 +123,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput) } export async function verifyEmail(input: VerifyEmailInput) { - const user = userRepo.getUserByVerifyToken(input.token) + const tokenHash = hashToken(input.token) + const user = userRepo.getUserByVerifyTokenHash(tokenHash) if (!user || !user.verify_expires || Date.now() > user.verify_expires) { throw new AppError(400, 'Invalid or expired verification token') } @@ -141,17 +147,19 @@ export async function forgotPassword(email?: string) { return } - const resetToken = generateVerificationToken() + const rawResetToken = generateVerificationToken() + const resetTokenHash = hashToken(rawResetToken) const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS - userRepo.updateUser(user.id, { resetToken, resetExpires }) + userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires }) // Fire and forget - sendPasswordResetEmail(user.email, resetToken).catch(() => {}) + sendPasswordResetEmail(user.email, rawResetToken).catch(() => {}) } export async function resetPassword(input: ResetPasswordInput) { - const user = userRepo.getUserByResetToken(input.token) + const tokenHash = hashToken(input.token) + const user = userRepo.getUserByResetTokenHash(tokenHash) if (!user || !user.reset_expires || Date.now() > user.reset_expires) { throw new AppError(400, 'Invalid or expired reset token') } diff --git a/backend/src/utils/sanitizeExam.ts b/backend/src/utils/sanitizeExam.ts new file mode 100644 index 0000000..230ad52 --- /dev/null +++ b/backend/src/utils/sanitizeExam.ts @@ -0,0 +1,30 @@ +/** + * Server-side sanitization for user-provided exam content. + * Removes script tags and event handlers from string fields. + */ + +function sanitizeString(value: unknown): unknown { + if (typeof value !== 'string') return value + return value + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*\/>/gi, '') + .replace(/javascript:/gi, '') + .replace(/on\w+\s*=/gi, '') +} + +function sanitizeObject(obj: unknown): unknown { + if (typeof obj === 'string') return sanitizeString(obj) + if (Array.isArray(obj)) return obj.map(sanitizeObject) + if (obj && typeof obj === 'object') { + const result: Record = {} + for (const [key, val] of Object.entries(obj)) { + result[key] = sanitizeObject(val) + } + return result + } + return obj +} + +export function sanitizeExam(exam: Record): Record { + return sanitizeObject(exam) as Record +} diff --git a/backend/src/utils/tokenBlacklist.ts b/backend/src/utils/tokenBlacklist.ts new file mode 100644 index 0000000..5e1e1e9 --- /dev/null +++ b/backend/src/utils/tokenBlacklist.ts @@ -0,0 +1,46 @@ +/** + * In-memory token blacklist with SQLite persistence. + * Tokens are blacklisted on logout and expire automatically based on JWT expiry. + */ + +import { getDb } from '../db/database.js' + +const memoryCache = new Set() +let initialized = false + +function initCache(): void { + if (initialized) return + const db = getDb() + db.exec(` + CREATE TABLE IF NOT EXISTS token_blacklist ( + token_hash TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at); + `) + // Load recent blacklisted tokens into memory + const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?') + .all(Date.now()) as { token_hash: string }[] + for (const row of rows) { + memoryCache.add(row.token_hash) + } + initialized = true +} + +export function isTokenBlacklisted(tokenHash: string): boolean { + initCache() + return memoryCache.has(tokenHash) +} + +export function blacklistToken(tokenHash: string, expiresAt: number): void { + initCache() + memoryCache.add(tokenHash) + const db = getDb() + db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)') + .run(tokenHash, expiresAt) +} + +export function cleanupExpiredTokens(): void { + const db = getDb() + db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now()) +} diff --git a/backend/src/utils/tokens.ts b/backend/src/utils/tokens.ts index 40acc8e..8939a7b 100644 --- a/backend/src/utils/tokens.ts +++ b/backend/src/utils/tokens.ts @@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload { export function generateVerificationToken(): string { return crypto.randomBytes(32).toString('hex') } + +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex') +} diff --git a/backend/src/validators/adminSchemas.test.ts b/backend/src/validators/adminSchemas.test.ts index 6795870..b3c67de 100644 --- a/backend/src/validators/adminSchemas.test.ts +++ b/backend/src/validators/adminSchemas.test.ts @@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => { describe('updateSettingSchema', () => { it('accepts valid setting', () => { - const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' }) + const result = updateSettingSchema.safeParse({ key: 'site_title', value: 'BrainWave' }) expect(result.success).toBe(true) }) @@ -30,7 +30,7 @@ describe('updateSettingSchema', () => { }) it('rejects empty value', () => { - const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' }) + const result = updateSettingSchema.safeParse({ key: 'site_title', value: '' }) expect(result.success).toBe(false) }) }) diff --git a/backend/src/validators/adminSchemas.ts b/backend/src/validators/adminSchemas.ts index a2e1593..fac8f90 100644 --- a/backend/src/validators/adminSchemas.ts +++ b/backend/src/validators/adminSchemas.ts @@ -5,8 +5,26 @@ export const updateUserRoleSchema = z.object({ role: z.enum(['user', 'admin']), }) +const ALLOWED_SETTING_KEYS = [ + 'site_title', + 'site_language', + 'maintenance_mode', + 'maintenance_message', + 'allow_guest_mode', + 'registration_enabled', + 'default_theme', + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_pass', + 'smtp_from', + 'smtp_secure', + 'app_url', + 'max_upload_mb', +] as const + export const updateSettingSchema = z.object({ - key: z.string().min(1), + key: z.enum(ALLOWED_SETTING_KEYS), value: z.string().min(1), }) diff --git a/backend/src/validators/authSchemas.test.ts b/backend/src/validators/authSchemas.test.ts index 3d511bb..0db7e1d 100644 --- a/backend/src/validators/authSchemas.test.ts +++ b/backend/src/validators/authSchemas.test.ts @@ -5,7 +5,7 @@ describe('registerSchema', () => { it('accepts valid registration', () => { const result = registerSchema.safeParse({ email: 'test@example.com', - password: 'password123', + password: 'Password123!', displayName: 'Test User', }) expect(result.success).toBe(true) @@ -14,7 +14,7 @@ describe('registerSchema', () => { it('rejects invalid email', () => { const result = registerSchema.safeParse({ email: 'not-an-email', - password: 'password123', + password: 'Password123!', displayName: 'Test User', }) expect(result.success).toBe(false) @@ -32,7 +32,7 @@ describe('registerSchema', () => { it('rejects empty display name', () => { const result = registerSchema.safeParse({ email: 'test@example.com', - password: 'password123', + password: 'Password123!', displayName: '', }) expect(result.success).toBe(false) @@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => { describe('resetPasswordSchema', () => { it('accepts valid reset', () => { - const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' }) + const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' }) expect(result.success).toBe(true) }) @@ -91,7 +91,7 @@ describe('changePasswordSchema', () => { it('accepts valid change', () => { const result = changePasswordSchema.safeParse({ currentPassword: 'oldpass', - newPassword: 'newpass123', + newPassword: 'NewPass123!', }) expect(result.success).toBe(true) }) diff --git a/backend/src/validators/authSchemas.ts b/backend/src/validators/authSchemas.ts index 7b357a0..ee0473f 100644 --- a/backend/src/validators/authSchemas.ts +++ b/backend/src/validators/authSchemas.ts @@ -1,8 +1,12 @@ import { z } from 'zod' +const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ + export const registerSchema = z.object({ email: z.string().email().max(255), - password: z.string().min(8, 'Password must be at least 8 characters'), + password: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), displayName: z.string().min(1, 'Display name is required').max(100), }) @@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({ export const resetPasswordSchema = z.object({ token: z.string().min(1, 'Token is required'), - newPassword: z.string().min(8, 'Password must be at least 8 characters'), + newPassword: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), }) export const updateProfileSchema = z.object({ @@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({ export const changePasswordSchema = z.object({ currentPassword: z.string(), - newPassword: z.string().min(8, 'Password must be at least 8 characters'), + newPassword: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), }) export const forgotPasswordSchema = z.object({ diff --git a/backend/src/validators/setupSchemas.test.ts b/backend/src/validators/setupSchemas.test.ts index 843405f..c122f83 100644 --- a/backend/src/validators/setupSchemas.test.ts +++ b/backend/src/validators/setupSchemas.test.ts @@ -5,7 +5,7 @@ describe('setupSchema', () => { it('accepts valid setup', () => { const result = setupSchema.safeParse({ email: 'admin@example.com', - password: 'adminpass123', + password: 'AdminPass123!', displayName: 'Admin', siteTitle: 'BrainWave', language: 'en', @@ -16,7 +16,7 @@ describe('setupSchema', () => { it('rejects invalid email', () => { const result = setupSchema.safeParse({ email: 'bad', - password: 'adminpass123', + password: 'AdminPass123!', displayName: 'Admin', }) expect(result.success).toBe(false) diff --git a/backend/src/validators/setupSchemas.ts b/backend/src/validators/setupSchemas.ts index 2fd770f..d61962d 100644 --- a/backend/src/validators/setupSchemas.ts +++ b/backend/src/validators/setupSchemas.ts @@ -1,8 +1,12 @@ import { z } from 'zod' +const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ + export const setupSchema = z.object({ email: z.string().email().max(255), - password: z.string().min(8, 'Password must be at least 8 characters'), + password: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'), displayName: z.string().min(1, 'Display name is required').max(100), siteTitle: z.string().min(1, 'Site title is required').max(255).optional(), language: z.enum(['en', 'nl']).optional(), diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json deleted file mode 100644 index 47cf8b8..0000000 --- a/node_modules/.package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "brainwave", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json index f277992..34764d0 100644 --- a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -1 +1 @@ -{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]} \ No newline at end of file +{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.415200000000027,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":6.726200000000006,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.416300000000035,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.132000000000005,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.569299999999998,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":7.313600000000008,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.513400000000047,"failed":false}],[":backend/dist/config.test.js",{"duration":3.9307000000000016,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.670799999999986,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":10.730400000000031,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.168000000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0274,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.865000000000009,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":130.3977,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.94319999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":130.18120000000005,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.06280000000001,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.779500000000013,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.795000000000016,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.715100000000007,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":3.313999999999993,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.088700000000017,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.638200000000012,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.546299999999974,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.1704000000000008,"failed":false}]]} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 47cf8b8..9221bdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,20 @@ "name": "brainwave", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "helmet": "^8.1.0" + } + }, + "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" + } + } + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..575b81c --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "brainwave", + "private": true, + "version": "0.0.1" +} -- 2.54.0 From 6dd87972f6d99afa4cf633432e99b3ec14e2d301 Mon Sep 17 00:00:00 2001 From: LaurensBartelds <48316768+LaurensBartelds@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:06:01 +0200 Subject: [PATCH 3/3] feat: update settings management to use site_name and add batch update schema --- backend/src/routes/admin.ts | 11 +++---- backend/src/routes/setup.ts | 6 ++-- backend/src/validators/adminSchemas.test.ts | 29 +++++++++++++++++-- backend/src/validators/adminSchemas.ts | 9 +++++- .../results.json | 2 +- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 28e37a7..e47d2ea 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -6,7 +6,7 @@ import { AppError } from '../middleware/error.js' import * as userRepo from '../repositories/userRepo.js' import * as resultRepo from '../repositories/resultRepo.js' import * as settingsRepo from '../repositories/settingsRepo.js' -import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js' +import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js' import type { Request } from 'express' const router = Router() @@ -124,17 +124,18 @@ router.get( router.get( '/settings', asyncHandler(async (_req, res) => { - res.json(settingsRepo.getAllSettings()) + res.json({ settings: settingsRepo.getAllSettings() }) }) ) // PUT /api/admin/settings router.put( '/settings', - validateBody(updateSettingSchema), + validateBody(updateSettingsBatchSchema), asyncHandler(async (req: Request, res) => { - const { key, value } = req.body - settingsRepo.setSetting(key, value) + const { settings } = req.body + const entries = Object.entries(settings) as Array<[string, string]> + settingsRepo.setSettingsBatch(entries) res.json({ success: true }) }) ) diff --git a/backend/src/routes/setup.ts b/backend/src/routes/setup.ts index 07a5adb..92b2919 100644 --- a/backend/src/routes/setup.ts +++ b/backend/src/routes/setup.ts @@ -14,7 +14,7 @@ const router = Router() const SALT_ROUNDS = 10 function getSiteTitle(): string | null { - return settingsRepo.getSetting('site_title') ?? null + return settingsRepo.getSetting('site_name') ?? null } // GET /api/setup/status @@ -38,7 +38,7 @@ router.post( // Atomic transaction prevents race conditions between concurrent setup requests const tx = db.transaction(() => { - const title = settingsRepo.getSetting('site_title') + const title = settingsRepo.getSetting('site_name') if (title) { throw new AppError(400, 'Setup already completed') } @@ -74,7 +74,7 @@ router.post( }) const settings: Array<[string, string]> = [ - ['site_title', siteTitle?.trim() || 'BrainWave'], + ['site_name', siteTitle?.trim() || 'BrainWave'], ['site_language', language || 'en'], ['maintenance_mode', 'false'], ] diff --git a/backend/src/validators/adminSchemas.test.ts b/backend/src/validators/adminSchemas.test.ts index b3c67de..d2f14ce 100644 --- a/backend/src/validators/adminSchemas.test.ts +++ b/backend/src/validators/adminSchemas.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas' +import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas' describe('updateUserRoleSchema', () => { it('accepts valid role change', () => { @@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => { describe('updateSettingSchema', () => { it('accepts valid setting', () => { - const result = updateSettingSchema.safeParse({ key: 'site_title', value: 'BrainWave' }) + const result = updateSettingSchema.safeParse({ key: 'site_name', value: 'BrainWave' }) expect(result.success).toBe(true) }) @@ -30,7 +30,30 @@ describe('updateSettingSchema', () => { }) it('rejects empty value', () => { - const result = updateSettingSchema.safeParse({ key: 'site_title', value: '' }) + const result = updateSettingSchema.safeParse({ key: 'site_name', value: '' }) + expect(result.success).toBe(false) + }) +}) + +describe('updateSettingsBatchSchema', () => { + it('accepts valid batch', () => { + const result = updateSettingsBatchSchema.safeParse({ + settings: { site_name: 'BrainWave', maintenance_mode: 'false' }, + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid key in batch', () => { + const result = updateSettingsBatchSchema.safeParse({ + settings: { site_name: 'BrainWave', unknown_key: 'value' }, + }) + expect(result.success).toBe(false) + }) + + it('rejects empty value in batch', () => { + const result = updateSettingsBatchSchema.safeParse({ + settings: { site_name: '' }, + }) expect(result.success).toBe(false) }) }) diff --git a/backend/src/validators/adminSchemas.ts b/backend/src/validators/adminSchemas.ts index fac8f90..10e7cc9 100644 --- a/backend/src/validators/adminSchemas.ts +++ b/backend/src/validators/adminSchemas.ts @@ -6,7 +6,7 @@ export const updateUserRoleSchema = z.object({ }) const ALLOWED_SETTING_KEYS = [ - 'site_title', + 'site_name', 'site_language', 'maintenance_mode', 'maintenance_message', @@ -28,6 +28,13 @@ export const updateSettingSchema = z.object({ value: z.string().min(1), }) +export const updateSettingsBatchSchema = z.object({ + settings: z.record(z.string().min(1)).refine( + (obj) => Object.keys(obj).every((k) => ALLOWED_SETTING_KEYS.includes(k as typeof ALLOWED_SETTING_KEYS[number])), + { message: 'One or more setting keys are not allowed' } + ), +}) + export const listUsersQuerySchema = z.object({ page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)), search: z.string().optional(), diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json index 34764d0..67df6f1 100644 --- a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -1 +1 @@ -{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.415200000000027,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":6.726200000000006,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.416300000000035,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.132000000000005,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.569299999999998,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":7.313600000000008,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.513400000000047,"failed":false}],[":backend/dist/config.test.js",{"duration":3.9307000000000016,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.670799999999986,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":10.730400000000031,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.168000000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0274,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.865000000000009,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":130.3977,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.94319999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":130.18120000000005,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.06280000000001,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.779500000000013,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.795000000000016,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.715100000000007,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":3.313999999999993,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.088700000000017,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.638200000000012,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.546299999999974,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.1704000000000008,"failed":false}]]} \ No newline at end of file +{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.013800000000003,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.808800000000019,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.428100000000029,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.740099999999984,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.838999999999999,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.458100000000002,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.4299000000000035,"failed":false}],[":backend/dist/config.test.js",{"duration":3.8840999999999894,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":8.53079999999997,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.639899999999955,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.222399999999993,"failed":false}],[":backend/src/config.test.ts",{"duration":4.612200000000001,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.7660000000000196,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":135.6874,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":5.303899999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":133.43280000000001,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.7246000000000095,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.102000000000004,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.396100000000018,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.6451999999999884,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.9522999999999797,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.4879999999999995,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.3067000000000064,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.663899999999984,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.3597999999999786,"failed":false}]]} \ No newline at end of file -- 2.54.0