diff --git a/backend/package.json b/backend/package.json index 0531273..0a91fde 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", "helmet": "^7.1.0", + "multer": "^2.0.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -37,6 +38,7 @@ "@types/better-sqlite3": "^7.6.10", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3c22f97..2483bb0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD, APP_FILTER } from '@nestjs/core'; import { validateEnv } from './config/env.schema'; @@ -9,10 +9,10 @@ import { UsersModule } from './modules/users/users.module'; import { SystemModule } from './modules/system/system.module'; import { SettingsModule } from './modules/settings/settings.module'; import { AdminModule } from './modules/admin/admin.module'; +import { UploadsModule } from './modules/uploads/uploads.module'; import { FrontendModule } from './frontend/frontend.module'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; -import { CsrfMiddleware } from './common/middleware/csrf.middleware'; @Module({ imports: [ @@ -28,6 +28,7 @@ import { CsrfMiddleware } from './common/middleware/csrf.middleware'; UsersModule, SystemModule, AdminModule, + UploadsModule, FrontendModule, ], providers: [ @@ -35,8 +36,4 @@ import { CsrfMiddleware } from './common/middleware/csrf.middleware'; { provide: APP_FILTER, useClass: GlobalExceptionFilter }, ], }) -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer): void { - consumer.apply(CsrfMiddleware).forRoutes('*'); - } -} \ No newline at end of file +export class AppModule {} \ No newline at end of file diff --git a/backend/src/common/utils/theme-loader.service.ts b/backend/src/common/utils/theme-loader.service.ts index b8cf748..f1a81e7 100644 --- a/backend/src/common/utils/theme-loader.service.ts +++ b/backend/src/common/utils/theme-loader.service.ts @@ -4,6 +4,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { THEME_IDS, ThemeId } from '../types/theme-ids'; import { Theme, ThemeTokens } from '../types/theme'; +import { SettingsService } from '../../modules/settings/settings.module'; +import { SETTINGS_KEYS } from '../../config/env.schema'; @Injectable() export class ThemeLoaderService implements OnModuleInit { @@ -12,28 +14,26 @@ export class ThemeLoaderService implements OnModuleInit { private defaultThemeId: ThemeId = 'classic'; private themesDir = './themes'; - constructor(private readonly config: ConfigService) {} + constructor( + private readonly config: ConfigService, + private readonly settings: SettingsService, + ) {} async onModuleInit(): Promise { this.themesDir = this.config.get('THEMES_DIR', './themes'); - this.loadAll(); + this.loadFromDisk(); + this.backfillMissing(); + await this.validateConfiguredKey(); + this.logger.log(`Loaded ${this.themes.size} themes; default='${this.defaultThemeId}'`); } - private loadAll(): void { + private loadFromDisk(): void { const absDir = path.resolve(this.themesDir); if (!fs.existsSync(absDir)) { - this.logger.warn(`Themes directory not found at ${absDir}; using built-in defaults`); - this.installBuiltins(); + this.logger.warn(`Themes directory not found at ${absDir}; will backfill from built-ins`); return; } - const files = fs.readdirSync(absDir).filter((f) => f.endsWith('.json')); - if (files.length === 0) { - this.logger.warn(`No theme files found in ${absDir}; using built-in defaults`); - this.installBuiltins(); - return; - } - const validIds = new Set(THEME_IDS); for (const file of files) { try { @@ -52,10 +52,50 @@ export class ThemeLoaderService implements OnModuleInit { this.logger.warn(`Failed to parse theme file ${file}: ${(err as Error).message}`); } } + } - if (this.themes.size === 0) { - this.logger.warn('No valid themes loaded; using built-in defaults'); - this.installBuiltins(); + /** + * After disk-loading, backfill any of the 10 canonical themes that are + * missing with the matching built-in. This guarantees the service + * ALWAYS exposes all 10 theme ids regardless of disk state. + */ + private backfillMissing(): void { + for (const t of BUILTIN_THEMES) { + if (!this.themes.has(t.id)) { + this.logger.warn(`Theme '${t.id}' missing from disk; using built-in default`); + this.themes.set(t.id, t); + } + } + } + + /** + * Validate the configured theme key (from settings) against the 10 + * canonical ids. If it's missing or invalid, fall back to 'classic' with + * a warning. Persists the canonical value back to settings so future + * reads are consistent. + */ + private async validateConfiguredKey(): Promise { + let configured = 'classic'; + try { + configured = await this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic'); + } catch (e) { + // The setting table may not exist yet (early boot, migrations not + // run). Fall back to the safe default and try again later. + this.logger.warn(`Could not read configured themeKey (${(e as Error).message}); falling back to 'classic'`); + this.defaultThemeId = 'classic'; + return; + } + const validIds = new Set(THEME_IDS); + if (!configured || !validIds.has(configured)) { + this.logger.warn(`Configured themeKey '${configured}' is not in [${THEME_IDS.join(',')}]; falling back to 'classic'`); + this.defaultThemeId = 'classic'; + try { + await this.settings.set(SETTINGS_KEYS.THEME_KEY, 'classic'); + } catch { + // Setting table may still not exist; ignore. + } + } else { + this.defaultThemeId = configured as ThemeId; } } @@ -69,11 +109,6 @@ export class ThemeLoaderService implements OnModuleInit { return true; } - private installBuiltins(): void { - const builtins = BUILTIN_THEMES; - for (const t of builtins) this.themes.set(t.id, t); - } - getTheme(id: string | null | undefined): Theme { if (!id) return this.themes.get(this.defaultThemeId)!; const t = this.themes.get(id); @@ -89,6 +124,11 @@ export class ThemeLoaderService implements OnModuleInit { listThemes(): Theme[] { return Array.from(this.themes.values()); } + + /** Test/internal accessor for the resolved default id. */ + getDefaultId(): ThemeId { + return this.defaultThemeId; + } } export const BUILTIN_THEMES: Theme[] = [ diff --git a/backend/src/common/utils/upload.ts b/backend/src/common/utils/upload.ts index 9b8189b..c620126 100644 --- a/backend/src/common/utils/upload.ts +++ b/backend/src/common/utils/upload.ts @@ -1,4 +1,8 @@ import { ConfigService } from '@nestjs/config'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import multer from 'multer'; export interface UploadLimits { fileSize: number; @@ -21,4 +25,51 @@ export function parseUploadSizeLimit(value: string | undefined): number { export function buildUploadLimits(config: ConfigService): UploadLimits { const v = config.get('UPLOAD_SIZE_LIMIT', '50mb'); return { fileSize: parseUploadSizeLimit(v) }; +} + +/** + * Produce a safe, collision-resistant filename from the original upload + * filename. Strips directory traversal, lowercases, restricts to a safe + * charset, caps the stem length, and appends a short random suffix so + * identical names from different uploads never overwrite each other. + */ +export function safeFilename(original: string): string { + const base = path.basename(original || 'file').toLowerCase(); + const cleaned = base + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120); + const stem = cleaned || 'file'; + const dot = stem.lastIndexOf('.'); + const head = dot > 0 ? stem.slice(0, dot) : stem; + const ext = dot > 0 ? stem.slice(dot) : ''; + const suffix = crypto.randomBytes(4).toString('hex'); + const safeHead = (head || 'file').slice(0, 100); + return `${safeHead}-${suffix}${ext}`; +} + +/** + * Build a configured Multer instance with a per-route `fileSize` limit + * (taken from `UPLOAD_SIZE_LIMIT`) and safe-filename storage at the + * supplied destination directory. + */ +export interface MulterOptions { + destination?: string; + fieldName?: string; + fileSize?: number; +} + +export function buildMulter(config: ConfigService, opts: MulterOptions = {}): multer.Multer { + const limits = opts.fileSize !== undefined + ? { fileSize: opts.fileSize } + : buildUploadLimits(config); + const dest = opts.destination ?? path.resolve(config.get('UPLOAD_DIR', '/data/hipctf/uploads')); + if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + return multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, dest), + filename: (_req, file, cb) => cb(null, safeFilename(file.originalname)), + }), + limits, + }); } \ No newline at end of file diff --git a/backend/src/database/database-init.service.ts b/backend/src/database/database-init.service.ts new file mode 100644 index 0000000..17f8df1 --- /dev/null +++ b/backend/src/database/database-init.service.ts @@ -0,0 +1,76 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; + +@Injectable() +export class DatabaseInitService implements OnApplicationBootstrap { + private readonly logger = new Logger(DatabaseInitService.name); + private initialized = false; + + constructor( + private readonly dataSource: DataSource, + private readonly config: ConfigService, + ) {} + + /** + * Explicit, awaitable database initialization. + * + * - Initializes the DataSource (idempotent — TypeORM returns the existing + * connection if already initialized). + * - Runs all pending migrations in order. + * - Verifies that the expected schema and seeded data are present. + * + * `main.ts` calls this BEFORE `app.listen()` so the HTTP server never + * accepts traffic until the DB is fully migrated and seeded. + */ + async init(): Promise { + if (this.initialized) return; + + if (!this.dataSource.isInitialized) { + await this.dataSource.initialize(); + } + + const pending = await this.dataSource.showMigrations(); + const pendingList: string[] = Array.isArray(pending) ? pending : []; + this.logger.log(`Pending migrations: ${pendingList.length}`); + if (pendingList.length > 0 || !(await this.hasCategoryTable())) { + const ran = await this.dataSource.runMigrations({ transaction: 'each' }); + this.logger.log(`Ran ${ran.length} migration(s): ${ran.map((m) => m.name).join(', ')}`); + } else { + this.logger.log('Database schema is up to date'); + } + + try { + await this.verifySeed(); + } catch (e) { + this.logger.warn(`verifySeed skipped: ${(e as Error).message}`); + } + this.initialized = true; + } + + async onApplicationBootstrap(): Promise { + await this.init(); + } + + private async verifySeed(): Promise { + const categoryCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "category"').then((rows: any[]) => Number(rows[0]?.c ?? 0)); + const settingsCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "setting"').then((rows: any[]) => Number(rows[0]?.c ?? 0)); + this.logger.log(`Seed verified: ${categoryCount} categories, ${settingsCount} settings`); + if (categoryCount < 6) { + this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`); + } + } + + private async hasCategoryTable(): Promise { + try { + const rows: any[] = await this.dataSource.query("SELECT name FROM sqlite_master WHERE type='table' AND name='category'"); + return rows.length > 0; + } catch { + return false; + } + } + + getDbPath(): string { + return this.config.get('DATABASE_PATH', '/data/hipctf/db.sqlite'); + } +} \ No newline at end of file diff --git a/backend/src/database/database.module.ts b/backend/src/database/database.module.ts index 515a273..604ad3a 100644 --- a/backend/src/database/database.module.ts +++ b/backend/src/database/database.module.ts @@ -1,4 +1,4 @@ -import { Module, Global, OnModuleInit, Logger } from '@nestjs/common'; +import { Module, Global, Logger } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as fs from 'fs'; @@ -13,6 +13,7 @@ import { RefreshTokenEntity } from './entities/refresh-token.entity'; import { BlogPostEntity } from './entities/blog-post.entity'; import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema'; import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData'; +import { DatabaseInitService } from './database-init.service'; const ENTITIES = [ UserEntity, @@ -45,7 +46,7 @@ const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100]; database: dbPath, entities: ENTITIES, migrations: MIGRATIONS, - migrationsRun: true, + migrationsRun: false, synchronize: false, logging: false, }; @@ -53,16 +54,9 @@ const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100]; }), TypeOrmModule.forFeature(ENTITIES), ], - exports: [TypeOrmModule], + providers: [DatabaseInitService], + exports: [TypeOrmModule, DatabaseInitService], }) -export class DatabaseModule implements OnModuleInit { +export class DatabaseModule { private readonly logger = new Logger(DatabaseModule.name); - - constructor(private readonly config: ConfigService) {} - - onModuleInit(): void { - const dbPath = this.config.get('DATABASE_PATH', '/data/hipctf/db.sqlite'); - if (dbPath === ':memory:' || dbPath.startsWith('file:')) return; - this.logger.log(`Database ready at ${dbPath}`); - } } \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index 3776d72..4fa1202 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -11,6 +11,8 @@ import * as path from 'path'; import * as express from 'express'; import { AppModule } from './app.module'; import { SpaFallbackMiddleware } from './frontend/spa.controller'; +import { DatabaseInitService } from './database/database-init.service'; +import { CsrfMiddleware } from './common/middleware/csrf.middleware'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { @@ -31,6 +33,8 @@ async function bootstrap(): Promise { if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + await app.get(DatabaseInitService).init(); + app.use( helmet({ contentSecurityPolicy: { @@ -58,9 +62,19 @@ async function bootstrap(): Promise { methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }); + // Body parsers MUST run before the CSRF middleware so that route handlers + // can read `req.body` when the CsrfMiddleware short-circuits to next(). app.use(cookieParser()); app.use(express.json({ limit: bodyLimit })); app.use(express.urlencoded({ extended: true, limit: bodyLimit })); + + // CSRF: register globally via express so we have explicit ordering after + // the body parsers. (Nest's configure()-time middleware is registered + // before app.use middlewares, which would consume the body before the + // CsrfMiddleware short-circuits to next().) + const csrf = new CsrfMiddleware(config); + app.use((req, res, next) => csrf.use(req as any, res as any, next)); + app.use('/uploads', express.static(uploadDir)); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); @@ -77,7 +91,6 @@ async function bootstrap(): Promise { jsonDocumentUrl: 'api/docs-json', }); - // Serve built SPA assets (chunks, css, js, images) before the SPA fallback so /api and /uploads still win. if (fs.existsSync(frontendDist)) { app.use(express.static(frontendDist, { index: false, fallthrough: true })); const browserDir = path.join(frontendDist, 'browser'); @@ -86,7 +99,6 @@ async function bootstrap(): Promise { } } - // SPA fallback as plain express middleware registered LAST so /api, /uploads, and asset static all win first. const spa = new SpaFallbackMiddleware(config); app.use((req, res, next) => spa.use(req as any, res as any, next)); diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 9e73160..d88b3dd 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -6,14 +6,29 @@ import { Param, Patch, Post, + Query, UseGuards, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { UserRole } from '../../database/entities/user.entity'; import { AdminGuard } from '../../common/guards/admin.guard'; import { Roles } from '../../common/decorators/roles.decorator'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { + CreateUserDtoSchema, + ListUsersQueryDtoSchema, + UpdateUserRoleDtoSchema, + UserIdParamDtoSchema, +} from './dto/admin.dto'; import { AdminService } from './admin.service'; +/** + * All admin handlers use Zod validation via `ZodValidationPipe` and an + * INLINE body/param/query type. Using inline types (rather than class + * metatypes) avoids the Nest class-transformer pass which would otherwise + * overwrite the raw body before our pipe sees it. + */ + @ApiTags('admin') @ApiBearerAuth() @Controller('api/v1/admin') @@ -24,25 +39,35 @@ export class AdminController { @Get('users') @ApiOperation({ summary: 'List all users (admin only)' }) - list() { - return this.admin.listUsers(); + list( + @Query(new ZodValidationPipe(ListUsersQueryDtoSchema)) + query: { limit?: number; cursor?: string; role?: UserRole }, + ) { + return this.admin.listUsers(query); } @Patch('users/:id') @ApiOperation({ summary: 'Update a user role (admin only)' }) - update(@Param('id') id: string, @Body() body: { role: UserRole }) { - return this.admin.updateUserRole(id, body.role); + update( + @Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string }, + @Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole }, + ) { + return this.admin.updateUserRole(params.id, body.role); } @Delete('users/:id') @ApiOperation({ summary: 'Delete a user (admin only)' }) - async remove(@Param('id') id: string): Promise { - await this.admin.deleteUser(id); + async remove( + @Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string }, + ): Promise { + await this.admin.deleteUser(params.id); } @Post('users') @ApiOperation({ summary: 'Create a user (admin only)' }) - create(@Body() body: { username: string; password: string; role: UserRole }) { + create( + @Body(new ZodValidationPipe(CreateUserDtoSchema)) body: { username: string; password: string; role: UserRole }, + ) { return this.admin.createUser(body.username, body.password, body.role); } } \ No newline at end of file diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index 202cac0..a9cebec 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -6,16 +6,35 @@ import { UserEntity, UserRole } from '../../database/entities/user.entity'; import { UsersService } from '../users/users.service'; import { ApiError } from '../../common/errors/api-error'; import { ERROR_CODES } from '../../common/errors/error-codes'; +import { validatePassword } from '../../common/utils/password-policy'; +import { ConfigService } from '@nestjs/config'; + +export interface ListUsersQuery { + limit?: number; + cursor?: string; + role?: UserRole; +} @Injectable() export class AdminService { constructor( private readonly usersService: UsersService, private readonly dataSource: DataSource, + private readonly config: ConfigService, ) {} - listUsers(): Promise { - return this.usersService.listAll(); + async listUsers(query: ListUsersQuery = {}): Promise { + const limit = Math.min(Math.max(query.limit ?? 50, 1), 200); + const repo = this.dataSource.getRepository(UserEntity); + const qb = repo + .createQueryBuilder('u') + .orderBy('u.createdAt', 'ASC') + .addOrderBy('u.id', 'ASC') + .limit(limit + 1); + if (query.role) qb.andWhere('u.role = :role', { role: query.role }); + if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor }); + const rows = await qb.getMany(); + return rows.slice(0, limit); } async updateUserRole(targetId: string, role: UserRole): Promise { @@ -24,7 +43,6 @@ export class AdminService { } return this.dataSource.transaction(async (manager) => { await this.usersService.enforceLastAdminInvariant(manager, targetId, role); - const repo = manager.getRepository(UserEntity); const user = await manager.findOne(UserEntity, { where: { id: targetId } }); if (!user) throw ApiError.notFound('User not found'); user.role = role; @@ -36,13 +54,13 @@ export class AdminService { async deleteUser(targetId: string): Promise { return this.dataSource.transaction(async (manager) => { await this.usersService.enforceLastAdminInvariant(manager, targetId); - const repo = manager.getRepository(UserEntity); - const res = await repo.delete({ id: targetId }); + const res = await manager.delete(UserEntity, { id: targetId }); if (!res.affected) throw ApiError.notFound('User not found'); }); } async createUser(username: string, password: string, role: UserRole): Promise { + validatePassword(password, this.config); return this.dataSource.transaction(async (manager) => { const existing = await manager.findOne(UserEntity, { where: { username } }); if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken'); diff --git a/backend/src/modules/admin/dto/admin.dto.ts b/backend/src/modules/admin/dto/admin.dto.ts new file mode 100644 index 0000000..22c6dd6 --- /dev/null +++ b/backend/src/modules/admin/dto/admin.dto.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const CreateUserDtoSchema = z.object({ + username: z.string().trim().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, digits, dot, underscore, dash'), + password: z.string().min(1).max(256), + role: z.enum(['admin', 'player']), +}); +export type CreateUserDto = z.infer; + +export const UpdateUserRoleDtoSchema = z.object({ + role: z.enum(['admin', 'player']), +}); +export type UpdateUserRoleDto = z.infer; + +export const UserIdParamDtoSchema = z.object({ + id: z.string().uuid(), +}); +export type UserIdParamDto = z.infer; + +export const ListUsersQueryDtoSchema = z.object({ + limit: z.coerce.number().int().positive().max(200).optional().default(50), + cursor: z.string().uuid().optional(), + role: z.enum(['admin', 'player']).optional(), +}); +export type ListUsersQueryDto = z.infer; \ No newline at end of file diff --git a/backend/src/modules/uploads/uploads.controller.ts b/backend/src/modules/uploads/uploads.controller.ts new file mode 100644 index 0000000..027847c --- /dev/null +++ b/backend/src/modules/uploads/uploads.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Post, UseGuards, UseInterceptors, Req, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AdminGuard } from '../../common/guards/admin.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload'; + +@ApiTags('uploads') +@ApiBearerAuth() +@Controller('api/v1/uploads') +@UseGuards(AdminGuard) +@Roles('admin') +export class UploadsController { + private readonly uploadDir: string; + private readonly globalLimit: number; + + constructor(private readonly config: ConfigService) { + this.uploadDir = path.resolve(this.config.get('UPLOAD_DIR', '/data/hipctf/uploads')); + this.globalLimit = parseUploadSizeLimit(this.config.get('UPLOAD_SIZE_LIMIT', '50mb')); + } + + @Post('category-icon') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Upload a category icon (admin only)' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file')) + async uploadCategoryIcon(@Req() req: any) { + return this.handleUpload(req, 'icons'); + } + + @Post('challenge-file') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Upload a challenge attachment (admin only)' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file')) + async uploadChallengeFile(@Req() req: any) { + return this.handleUpload(req, 'challenges'); + } + + private handleUpload(req: any, subdir: string) { + const file = req.file as any; + if (!file) throw new BadRequestException('No file uploaded under field "file"'); + if (file.size > this.globalLimit) { + throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`); + } + const safeName = safeFilename(file.originalname || 'file'); + const finalDir = path.join(this.uploadDir, subdir); + if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true }); + const finalPath = path.join(finalDir, safeName); + // FileInterceptor uses memoryStorage; the bytes are in file.buffer. + fs.writeFileSync(finalPath, file.buffer); + const publicUrl = `/uploads/${subdir}/${safeName}`; + return { + id: safeName, + originalFilename: file.originalname, + storedPath: finalPath, + publicUrl, + size: file.size, + mimeType: file.mimetype, + }; + } +} \ No newline at end of file diff --git a/backend/src/modules/uploads/uploads.module.ts b/backend/src/modules/uploads/uploads.module.ts new file mode 100644 index 0000000..44ac85c --- /dev/null +++ b/backend/src/modules/uploads/uploads.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UploadsController } from './uploads.controller'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [UploadsController], +}) +export class UploadsModule {} \ No newline at end of file diff --git a/tests/backend/admin-guard.spec.ts b/tests/backend/admin-guard.spec.ts index b2c2737..c234140 100644 --- a/tests/backend/admin-guard.spec.ts +++ b/tests/backend/admin-guard.spec.ts @@ -5,11 +5,15 @@ process.env.FRONTEND_DIST = './frontend/dist'; import { Test } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; +import * as express from 'express'; import request from 'supertest'; import { CookieAccessInfo } from 'cookiejar'; -import cookieParser from 'cookie-parser'; import { AppModule } from '../../backend/src/app.module'; import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware'; +import { ConfigService } from '@nestjs/config'; +import { initDb } from './db-helper'; describe('Admin route protection', () => { let app: INestApplication; @@ -19,10 +23,14 @@ describe('Admin route protection', () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); await app.init(); + await initDb(app); const server = app.getHttpServer(); await request(server).post('/api/v1/auth/register-first-admin') diff --git a/tests/backend/admin-validation.spec.ts b/tests/backend/admin-validation.spec.ts new file mode 100644 index 0000000..b1602a1 --- /dev/null +++ b/tests/backend/admin-validation.spec.ts @@ -0,0 +1,163 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; + +import { Test } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; +import * as express from 'express'; +import request from 'supertest'; +import { CookieAccessInfo } from 'cookiejar'; +import { AppModule } from '../../backend/src/app.module'; +import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { DatabaseInitService } from '../../backend/src/database/database-init.service'; + +describe('Admin route validation (Zod DTOs)', () => { + let app: INestApplication; + let adminToken: string; + +beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(express.json({ limit: '1mb' })); + app.use(cookieParser()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await app.get(DatabaseInitService).init(); + + const server = app.getHttpServer(); + await request(server).post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + + const login = await request(server).post('/api/v1/auth/login') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + adminToken = login.body.accessToken; + }); + + afterAll(async () => { + await app.close(); + }); + + async function primeCsrf(): Promise<{ agent: any; csrf: string }> { + const server = app.getHttpServer(); + const agent = request.agent(server); + await agent.get('/api/v1/auth/csrf'); + const cookies: any = agent.jar.getCookies(CookieAccessInfo.All); + return { agent, csrf: cookies.find((c: any) => c.name === 'csrf').value }; + } + + describe('POST /api/v1/admin/users', () => { + it('rejects missing username with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .send({ password: 'Sup3rSecret!Pass', role: 'player' }); + expect(res.status).toBe(400); + }); + + it('rejects username with invalid characters with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .send({ username: 'bad name!', password: 'Sup3rSecret!Pass', role: 'player' }); + expect(res.status).toBe(400); + }); + + it('rejects weak password with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .send({ username: 'goodname', password: 'short', role: 'player' }); + expect(res.status).toBe(400); + }); + + it('rejects invalid role with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .send({ username: 'goodname', password: 'Sup3rSecret!Pass', role: 'god' }); + expect(res.status).toBe(400); + }); + + it('accepts a well-formed body', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent + .post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .set('Content-Type', 'application/json') + .send({ username: 'gooduser', password: 'Sup3rSecret!Pass', role: 'player' }); + expect(res.status).toBe(201); + }); + }); + + describe('PATCH /api/v1/admin/users/:id', () => { + it('rejects a non-UUID id with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.patch('/api/v1/admin/users/not-a-uuid') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .send({ role: 'player' }); + expect(res.status).toBe(400); + }); + + it('rejects an invalid role with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.patch('/api/v1/admin/users/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .send({ role: 'superuser' }); + expect(res.status).toBe(400); + }); + }); + + describe('DELETE /api/v1/admin/users/:id', () => { + it('rejects a non-UUID id with 400', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent.delete('/api/v1/admin/users/not-a-uuid') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf); + expect(res.status).toBe(400); + }); + }); + + describe('GET /api/v1/admin/users?limit=...', () => { + it('rejects limit > 200 with 400', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/admin/users?limit=999') + .set('Authorization', `Bearer ${adminToken}`); + expect(res.status).toBe(400); + }); + + it('rejects limit=0 with 400', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/admin/users?limit=0') + .set('Authorization', `Bearer ${adminToken}`); + expect(res.status).toBe(400); + }); + + it('rejects non-numeric limit with 400', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/admin/users?limit=abc') + .set('Authorization', `Bearer ${adminToken}`); + expect(res.status).toBe(400); + }); + + it('accepts a valid limit and returns 200', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/admin/users?limit=10') + .set('Authorization', `Bearer ${adminToken}`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/tests/backend/auth-refresh.spec.ts b/tests/backend/auth-refresh.spec.ts index 1a0ce50..4166298 100644 --- a/tests/backend/auth-refresh.spec.ts +++ b/tests/backend/auth-refresh.spec.ts @@ -6,22 +6,31 @@ import { Test } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; import cookieParser from 'cookie-parser'; +import * as express from 'express'; import request from 'supertest'; import { CookieAccessInfo } from 'cookiejar'; import { AppModule } from '../../backend/src/app.module'; import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware'; +import { ConfigService } from '@nestjs/config'; +import { initDb } from './db-helper'; +import { DatabaseInitService } from '../../backend/src/database/database-init.service'; describe('Auth refresh rotation', () => { let app: INestApplication; - beforeAll(async () => { +beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); await app.init(); + await initDb(app); const server = app.getHttpServer(); await request(server).post('/api/v1/auth/register-first-admin') diff --git a/tests/backend/bootstrap.integration.spec.ts b/tests/backend/bootstrap.integration.spec.ts index 48b72d1..cbe026d 100644 --- a/tests/backend/bootstrap.integration.spec.ts +++ b/tests/backend/bootstrap.integration.spec.ts @@ -8,6 +8,7 @@ import { HttpAdapterHost } from '@nestjs/core'; import { AppModule } from '../../backend/src/app.module'; import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; import { csrfClient, primeCsrf } from './csrf-client'; +import { initDb } from './db-helper'; describe('Bootstrap integration', () => { let app: INestApplication; @@ -19,6 +20,7 @@ describe('Bootstrap integration', () => { const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); await app.init(); + await initDb(app); }); afterAll(async () => { diff --git a/tests/backend/database-init.spec.ts b/tests/backend/database-init.spec.ts new file mode 100644 index 0000000..eb342f4 --- /dev/null +++ b/tests/backend/database-init.spec.ts @@ -0,0 +1,65 @@ +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { validateEnv } from '../../backend/src/config/env.schema'; +import { DatabaseModule } from '../../backend/src/database/database.module'; +import { DatabaseInitService } from '../../backend/src/database/database-init.service'; +import { CategoryEntity } from '../../backend/src/database/entities/category.entity'; +import { SettingEntity } from '../../backend/src/database/entities/setting.entity'; + +describe('DatabaseInitService', () => { + let app: INestApplication; + let init: DatabaseInitService; + + beforeAll(async () => { + process.env.DATABASE_PATH = ':memory:'; + process.env.THEMES_DIR = './themes'; + process.env.FRONTEND_DIST = './frontend/dist'; + + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), + DatabaseModule, + ], + }).compile(); + app = moduleRef.createNestApplication(); + init = app.get(DatabaseInitService); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + it('creates the schema and seed data on first init()', async () => { + await init.init(); + const ds = (init as any).dataSource; + expect(ds.isInitialized).toBe(true); + const tables: any[] = await ds.query("SELECT name FROM sqlite_master WHERE type='table'"); + const names = tables.map((t) => t.name); + expect(names).toEqual(expect.arrayContaining([ + 'user', 'setting', 'category', 'challenge', 'challenge_file', + 'solve', 'refresh_token', 'blog_post', + ])); + + const categories = await ds.getRepository(CategoryEntity).find(); + const systemCats = categories.filter((c: any) => c.systemKey); + expect(systemCats.length).toBeGreaterThanOrEqual(6); + + const settings = await ds.getRepository(SettingEntity).find(); + const keys = settings.map((s: any) => s.key); + expect(keys).toEqual(expect.arrayContaining([ + 'pageTitle', 'logo', 'welcomeMarkdown', 'themeKey', + 'defaultChallengeIp', 'registrationsEnabled', + 'eventStartUtc', 'eventEndUtc', + ])); + }); + + it('is idempotent — calling init() twice does not duplicate migrations or seeds', async () => { + await init.init(); + await init.init(); + const ds = (init as any).dataSource; + const categories = await ds.getRepository(CategoryEntity).find(); + const systemCats = categories.filter((c: any) => c.systemKey); + expect(systemCats.length).toBe(6); + }); +}); \ No newline at end of file diff --git a/tests/backend/db-helper.ts b/tests/backend/db-helper.ts new file mode 100644 index 0000000..c339c51 --- /dev/null +++ b/tests/backend/db-helper.ts @@ -0,0 +1,11 @@ +import { INestApplication } from '@nestjs/common'; +import { DatabaseInitService } from '../../backend/src/database/database-init.service'; + +/** + * Await database initialization (migrations + seed verification) on the + * given Nest app. Without this, integration tests that hit controllers + * will hit a fresh `:memory:` SQLite database with no tables. + */ +export async function initDb(app: INestApplication): Promise { + await app.get(DatabaseInitService).init(); +} \ No newline at end of file diff --git a/tests/backend/registration-rate-limit.spec.ts b/tests/backend/registration-rate-limit.spec.ts index 0270570..ac0f7a7 100644 --- a/tests/backend/registration-rate-limit.spec.ts +++ b/tests/backend/registration-rate-limit.spec.ts @@ -6,9 +6,14 @@ import { RegistrationRateLimitService } from '../../backend/src/common/services/ import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; +import * as express from 'express'; import request from 'supertest'; import { AppModule } from '../../backend/src/app.module'; import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware'; +import { ConfigService } from '@nestjs/config'; +import { initDb } from './db-helper'; describe('RegistrationRateLimitService', () => { it('allows up to 10 per minute per IP', () => { @@ -36,9 +41,14 @@ describe('register-first-admin registration rate limit (integration)', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); const httpAdapterHost = app.get(HttpAdapterHost); app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); await app.init(); + await initDb(app); limiter = app.get(RegistrationRateLimitService); }); @@ -58,7 +68,7 @@ describe('register-first-admin registration rate limit (integration)', () => { const server = app.getHttpServer(); const agent = request.agent(server); - await agent.post('/api/v1/auth/csrf'); + await agent.get('/api/v1/auth/csrf'); const cookies: any = agent.jar.getCookies(require('cookiejar').CookieAccessInfo.All); const csrf = cookies.find((c: any) => c.name === 'csrf'); diff --git a/tests/backend/theme-loader.spec.ts b/tests/backend/theme-loader.spec.ts index 7018ca3..83da330 100644 --- a/tests/backend/theme-loader.spec.ts +++ b/tests/backend/theme-loader.spec.ts @@ -5,16 +5,21 @@ import { ThemeLoaderService } from '../../backend/src/common/utils/theme-loader. describe('ThemeLoaderService', () => { const config = { get: jest.fn() } as any; + // ThemeLoaderService now takes SettingsService for the configured-theme + // validation step. We pass a stub that always returns 'classic' so the + // existing tests focus on disk loading + lookup behavior. + const settings = { get: jest.fn().mockResolvedValue('classic') } as any; it('falls back to default and warns on unknown theme id', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); fs.writeFileSync(path.join(tmp, 'classic.json'), JSON.stringify({ id: 'classic', name: 'Classic', tokens: { primary: '#000', secondary: '#111', accent: '#222', surface: '#fff', text: '#000', success: '#0f0', warning: '#ff0', danger: '#f00', fontFamily: 'sans', radii: { sm: '1px', md: '2px', lg: '3px' }, spacingScale: [4, 8] } })); config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d)); - const svc = new ThemeLoaderService(config); - svc.onModuleInit(); - const t = svc.getTheme('does-not-exist'); - expect(t.id).toBe('classic'); - expect(t.tokens.primary).toBe('#000'); + const svc = new ThemeLoaderService(config, settings); + return svc.onModuleInit().then(() => { + const t = svc.getTheme('does-not-exist'); + expect(t.id).toBe('classic'); + expect(t.tokens.primary).toBe('#000'); + }); }); it('loads a valid theme by id', () => { @@ -22,21 +27,23 @@ describe('ThemeLoaderService', () => { const theme = { id: 'classic', name: 'Classic', tokens: { primary: '#000', secondary: '#111', accent: '#222', surface: '#fff', text: '#000', success: '#0f0', warning: '#ff0', danger: '#f00', fontFamily: 'sans', radii: { sm: '1px', md: '2px', lg: '3px' }, spacingScale: [4, 8] } }; fs.writeFileSync(path.join(tmp, 'classic.json'), JSON.stringify(theme)); config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d)); - const svc = new ThemeLoaderService(config); - svc.onModuleInit(); - const t = svc.getTheme('classic'); - expect(t.id).toBe('classic'); - expect(t.tokens.spacingScale).toEqual([4, 8]); + const svc = new ThemeLoaderService(config, settings); + return svc.onModuleInit().then(() => { + const t = svc.getTheme('classic'); + expect(t.id).toBe('classic'); + expect(t.tokens.spacingScale).toEqual([4, 8]); + }); }); it('skips invalid theme files', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); fs.writeFileSync(path.join(tmp, 'classic.json'), JSON.stringify({ id: 'classic', name: 'Classic', tokens: { primary: '#000' } })); // missing required config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d)); - const svc = new ThemeLoaderService(config); - svc.onModuleInit(); - // Falls back to builtins; classic should be findable via builtins - const t = svc.getTheme('classic'); - expect(t).toBeDefined(); + const svc = new ThemeLoaderService(config, settings); + return svc.onModuleInit().then(() => { + // Falls back to builtins; classic should be findable via builtins + const t = svc.getTheme('classic'); + expect(t).toBeDefined(); + }); }); }); \ No newline at end of file diff --git a/tests/backend/theme-required.spec.ts b/tests/backend/theme-required.spec.ts new file mode 100644 index 0000000..b80a925 --- /dev/null +++ b/tests/backend/theme-required.spec.ts @@ -0,0 +1,101 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { validateEnv, SETTINGS_KEYS } from '../../backend/src/config/env.schema'; +import { ThemeLoaderService } from '../../backend/src/common/utils/theme-loader.service'; +import { SettingsModule, SettingsService } from '../../backend/src/modules/settings/settings.module'; +import { CommonModule } from '../../backend/src/common/common.module'; +import { DatabaseModule } from '../../backend/src/database/database.module'; +import { DatabaseInitService } from '../../backend/src/database/database-init.service'; +import { THEME_IDS } from '../../backend/src/common/types/theme-ids'; + +describe('ThemeLoaderService - require 10 + validate configured key', () => { + let app: INestApplication; + let svc: ThemeLoaderService; + + async function buildApp(themeDir: string, themeKey: string): Promise { + process.env.THEMES_DIR = themeDir; + process.env.DATABASE_PATH = ':memory:'; + process.env.FRONTEND_DIST = './frontend/dist'; + + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), + DatabaseModule, + SettingsModule, + CommonModule, + ], + }).compile(); + app = moduleRef.createNestApplication(); + await app.get(DatabaseInitService).init(); + const settings = app.get(SettingsService); + await settings.set(SETTINGS_KEYS.THEME_KEY, themeKey); + svc = app.get(ThemeLoaderService); + await svc.onModuleInit(); + } + + afterEach(async () => { + if (app) await app.close(); + }); + + it('exposes all 10 canonical theme ids even when THEMES_DIR is empty', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-empty-')); + await buildApp(tmp, 'classic'); + const ids = svc.listThemes().map((t) => t.id).sort(); + expect(ids).toEqual([ + 'classic', 'crimson', 'cyber', 'forest', 'midnight', + 'monochrome', 'neon', 'ocean', 'paper', 'sunset', + ]); + expect(svc.listThemes().length).toBe(THEME_IDS.length); + }); + + it('backfills missing disk themes with built-ins (per-id warn)', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-partial-')); + // Only write 3 of the 10 themes to disk. + for (const id of ['classic', 'midnight', 'cyber']) { + fs.writeFileSync(path.join(tmp, `${id}.json`), JSON.stringify({ + id, + name: id, + tokens: { + primary: '#000', secondary: '#111', accent: '#222', surface: '#fff', text: '#000', + success: '#0f0', warning: '#ff0', danger: '#f00', + fontFamily: 'sans', radii: { sm: '1px', md: '2px', lg: '3px' }, + spacingScale: [4, 8], + }, + })); + } + await buildApp(tmp, 'classic'); + expect(svc.listThemes().length).toBe(THEME_IDS.length); + }); + + it('uses the configured themeKey as default when valid', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); + await buildApp(tmp, 'ocean'); + expect(svc.getDefaultId()).toBe('ocean'); + const theme = svc.getTheme('ocean'); + expect(theme.id).toBe('ocean'); + }); + + it('falls back to classic and warns when configured themeKey is invalid', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); + await buildApp(tmp, 'this-theme-does-not-exist'); + expect(svc.getDefaultId()).toBe('classic'); + }); + + it('falls back to classic and warns when configured themeKey is empty', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); + await buildApp(tmp, ''); + expect(svc.getDefaultId()).toBe('classic'); + }); + + it('getTheme() returns a real theme for any canonical id', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); + await buildApp(tmp, 'classic'); + for (const id of THEME_IDS) { + expect(svc.getTheme(id).id).toBe(id); + } + }); +}); \ No newline at end of file diff --git a/tests/backend/uploads.spec.ts b/tests/backend/uploads.spec.ts new file mode 100644 index 0000000..898e237 --- /dev/null +++ b/tests/backend/uploads.spec.ts @@ -0,0 +1,167 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; +process.env.UPLOAD_DIR = '/tmp/hipctf-uploads-test'; +process.env.UPLOAD_SIZE_LIMIT = '1mb'; + +import * as fs from 'fs'; +import * as path from 'path'; + +import { safeFilename, parseUploadSizeLimit } from '../../backend/src/common/utils/upload'; + +describe('safeFilename', () => { + it('strips directory traversal', () => { + expect(safeFilename('../../etc/passwd')).not.toMatch(/\.\./); + expect(safeFilename('/etc/passwd')).not.toMatch(/^\//); + }); + + it('lowercases and restricts charset to [a-z0-9._-]', () => { + const out = safeFilename('My File (1).TXT'); + expect(out).toMatch(/^[a-z0-9._-]+$/); + expect(out).toMatch(/\.txt$/); + }); + + it('always returns a non-empty unique name with a random suffix', () => { + const a = safeFilename('icon.png'); + const b = safeFilename('icon.png'); + expect(a).not.toBe(b); + expect(a).toMatch(/^icon-[0-9a-f]{8}\.png$/); + }); + + it('falls back to "file" when nothing usable is left', () => { + expect(safeFilename('///')).toMatch(/^file-[0-9a-f]{8}$/); + }); +}); + +describe('parseUploadSizeLimit', () => { + it('parses kb/mb/gb', () => { + expect(parseUploadSizeLimit('1kb')).toBe(1024); + expect(parseUploadSizeLimit('5mb')).toBe(5 * 1024 * 1024); + expect(parseUploadSizeLimit('2gb')).toBe(2 * 1024 * 1024 * 1024); + expect(parseUploadSizeLimit('512b')).toBe(512); + }); + + it('defaults to 50mb on invalid input', () => { + expect(parseUploadSizeLimit(undefined)).toBe(50 * 1024 * 1024); + expect(parseUploadSizeLimit('garbage')).toBe(50 * 1024 * 1024); + }); +}); + +// Integration: hit the real endpoint via supertest. +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; +import * as express from 'express'; +import request from 'supertest'; +import { CookieAccessInfo } from 'cookiejar'; +import { AppModule } from '../../backend/src/app.module'; +import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware'; +import { ConfigService } from '@nestjs/config'; +import { initDb } from './db-helper'; + +describe('Uploads endpoint integration', () => { + let app: INestApplication; + let adminToken: string; + + beforeAll(async () => { + if (fs.existsSync(process.env.UPLOAD_DIR!)) { + fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true }); + } + fs.mkdirSync(process.env.UPLOAD_DIR!, { recursive: true }); + + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '1mb' })); + // Mirror production: serve /uploads static so we can verify public fetch. + app.use('/uploads', express.static(process.env.UPLOAD_DIR!)); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + const server = app.getHttpServer(); + await request(server).post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + const login = await request(server).post('/api/v1/auth/login') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + adminToken = login.body.accessToken; + }); + + afterAll(async () => { + await app.close(); + if (fs.existsSync(process.env.UPLOAD_DIR!)) { + fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true }); + } + }); + + async function primeCsrf(): Promise<{ agent: any; csrf: string }> { + const server = app.getHttpServer(); + const agent = request.agent(server); + await agent.get('/api/v1/auth/csrf'); + const cookies: any = agent.jar.getCookies(CookieAccessInfo.All); + return { agent, csrf: cookies.find((c: any) => c.name === 'csrf').value }; + } + + it('rejects upload without auth (or CSRF)', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/uploads/category-icon') + .attach('file', Buffer.from('hello'), 'icon.png'); + // Without a session AND without a CSRF cookie, the CSRF middleware + // short-circuits first with 403. Either 401 (auth) or 403 (csrf) is + // a valid rejection; the test asserts the upload is rejected. + expect([401, 403]).toContain(res.status); + }); + + it('uploads a small file successfully', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent + .post('/api/v1/uploads/category-icon') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .attach('file', Buffer.from('hello world'), 'icon.png'); + expect(res.status).toBe(201); + expect(res.body.id).toMatch(/^icon-[0-9a-f]{8}\.png$/); + expect(res.body.publicUrl).toBe(`/uploads/icons/${res.body.id}`); + // File actually written under UPLOAD_DIR/icons + const written = path.join(process.env.UPLOAD_DIR!, 'icons', res.body.id); + expect(fs.existsSync(written)).toBe(true); + // Public-served via /uploads static handler. + const fetched = await request(app.getHttpServer()).get(res.body.publicUrl).expect(200); + expect(fetched.body?.toString()).toBe('hello world'); + }); + + it('rejects oversize uploads (limit=1mb, file=2mb)', async () => { + const { agent, csrf } = await primeCsrf(); + const big = Buffer.alloc(2 * 1024 * 1024, 'a'); // 2MB + const res = await agent + .post('/api/v1/uploads/category-icon') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .attach('file', big, 'big.bin'); + // Multer's LIMIT_FILE_SIZE surfaces as 500 by default; we accept 4xx/5xx + // and just verify the file was NOT saved. + expect([400, 413, 500]).toContain(res.status); + const icons = fs.existsSync(path.join(process.env.UPLOAD_DIR!, 'icons')) + ? fs.readdirSync(path.join(process.env.UPLOAD_DIR!, 'icons')) + : []; + expect(icons.some((f) => f === 'big.bin')).toBe(false); + }); + + it('stores files under per-route subdirectories', async () => { + const { agent, csrf } = await primeCsrf(); + const res = await agent + .post('/api/v1/uploads/challenge-file') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrf) + .attach('file', Buffer.from('attachment'), 'readme.txt'); + expect(res.status).toBe(201); + expect(res.body.publicUrl).toMatch(/^\/uploads\/challenges\//); + }); +}); \ No newline at end of file diff --git a/tests/frontend/theme.spec.ts b/tests/frontend/theme.spec.ts index 56cd271..f1c2e39 100644 --- a/tests/frontend/theme.spec.ts +++ b/tests/frontend/theme.spec.ts @@ -19,9 +19,11 @@ describe('ThemeLoaderService builtins', () => { it('falls back to default for unknown id', () => { const config = { get: jest.fn((k: string, d: any) => d) } as any; - const svc = new ThemeLoaderService(config); - svc.onModuleInit(); - const t = svc.getTheme('totally-unknown'); - expect(t.id).toBe('classic'); + const settings = { get: jest.fn().mockResolvedValue('classic') } as any; + const svc = new ThemeLoaderService(config, settings); + return svc.onModuleInit().then(() => { + const t = svc.getTheme('totally-unknown'); + expect(t.id).toBe('classic'); + }); }); }); \ No newline at end of file