Harden scaffold (reviewer followups)
- main.ts awaits DatabaseInitService.init() before app.listen(), ensuring
migrations + seed run before the HTTP server accepts traffic.
- AppModule now uses NestModule with consumer.apply() no longer needed for
CSRF (registered globally via app.use after body parsers in main.ts).
- JwtAuthGuard extended from AuthGuard('jwt') so protected endpoints
actually validate the bearer token.
- Admin controller now uses Zod-validated DTOs for body/path/query:
createUser, updateUserRole, userIdParam, listUsersQuery; with @Public
/ @Roles decorators and AdminGuard applied.
- Multer upload module + controller (POST /api/v1/uploads/category-icon
and /challenge-file) with safe-filename strategy, configured
UPLOAD_SIZE_LIMIT, admin-only via AdminGuard, served via /uploads
static handler.
- ThemeLoaderService now requires all 10 canonical theme ids at startup,
backfilling missing themes from built-ins with a warning; validates the
configured themeKey setting and falls back to 'classic' if invalid;
gracefully tolerates missing setting table during early boot.
- Test suite expanded to 76 tests / 17 suites; new specs:
admin-validation, uploads, theme-required, database-init.
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.10",
|
"@types/better-sqlite3": "^7.6.10",
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||||
import { validateEnv } from './config/env.schema';
|
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 { SystemModule } from './modules/system/system.module';
|
||||||
import { SettingsModule } from './modules/settings/settings.module';
|
import { SettingsModule } from './modules/settings/settings.module';
|
||||||
import { AdminModule } from './modules/admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
|
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||||
import { FrontendModule } from './frontend/frontend.module';
|
import { FrontendModule } from './frontend/frontend.module';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +28,7 @@ import { CsrfMiddleware } from './common/middleware/csrf.middleware';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
UploadsModule,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -35,8 +36,4 @@ import { CsrfMiddleware } from './common/middleware/csrf.middleware';
|
|||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule {}
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
|
||||||
consumer.apply(CsrfMiddleware).forRoutes('*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { THEME_IDS, ThemeId } from '../types/theme-ids';
|
import { THEME_IDS, ThemeId } from '../types/theme-ids';
|
||||||
import { Theme, ThemeTokens } from '../types/theme';
|
import { Theme, ThemeTokens } from '../types/theme';
|
||||||
|
import { SettingsService } from '../../modules/settings/settings.module';
|
||||||
|
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ThemeLoaderService implements OnModuleInit {
|
export class ThemeLoaderService implements OnModuleInit {
|
||||||
@@ -12,28 +14,26 @@ export class ThemeLoaderService implements OnModuleInit {
|
|||||||
private defaultThemeId: ThemeId = 'classic';
|
private defaultThemeId: ThemeId = 'classic';
|
||||||
private themesDir = './themes';
|
private themesDir = './themes';
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
this.themesDir = this.config.get<string>('THEMES_DIR', './themes');
|
this.themesDir = this.config.get<string>('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);
|
const absDir = path.resolve(this.themesDir);
|
||||||
if (!fs.existsSync(absDir)) {
|
if (!fs.existsSync(absDir)) {
|
||||||
this.logger.warn(`Themes directory not found at ${absDir}; using built-in defaults`);
|
this.logger.warn(`Themes directory not found at ${absDir}; will backfill from built-ins`);
|
||||||
this.installBuiltins();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = fs.readdirSync(absDir).filter((f) => f.endsWith('.json'));
|
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<string>(THEME_IDS);
|
const validIds = new Set<string>(THEME_IDS);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
@@ -52,10 +52,50 @@ export class ThemeLoaderService implements OnModuleInit {
|
|||||||
this.logger.warn(`Failed to parse theme file ${file}: ${(err as Error).message}`);
|
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');
|
* After disk-loading, backfill any of the 10 canonical themes that are
|
||||||
this.installBuiltins();
|
* 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<void> {
|
||||||
|
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<string>(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;
|
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 {
|
getTheme(id: string | null | undefined): Theme {
|
||||||
if (!id) return this.themes.get(this.defaultThemeId)!;
|
if (!id) return this.themes.get(this.defaultThemeId)!;
|
||||||
const t = this.themes.get(id);
|
const t = this.themes.get(id);
|
||||||
@@ -89,6 +124,11 @@ export class ThemeLoaderService implements OnModuleInit {
|
|||||||
listThemes(): Theme[] {
|
listThemes(): Theme[] {
|
||||||
return Array.from(this.themes.values());
|
return Array.from(this.themes.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test/internal accessor for the resolved default id. */
|
||||||
|
getDefaultId(): ThemeId {
|
||||||
|
return this.defaultThemeId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILTIN_THEMES: Theme[] = [
|
export const BUILTIN_THEMES: Theme[] = [
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
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 {
|
export interface UploadLimits {
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
@@ -22,3 +26,50 @@ export function buildUploadLimits(config: ConfigService): UploadLimits {
|
|||||||
const v = config.get<string>('UPLOAD_SIZE_LIMIT', '50mb');
|
const v = config.get<string>('UPLOAD_SIZE_LIMIT', '50mb');
|
||||||
return { fileSize: parseUploadSizeLimit(v) };
|
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<string>('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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifySeed(): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -13,6 +13,7 @@ import { RefreshTokenEntity } from './entities/refresh-token.entity';
|
|||||||
import { BlogPostEntity } from './entities/blog-post.entity';
|
import { BlogPostEntity } from './entities/blog-post.entity';
|
||||||
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
||||||
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
||||||
|
import { DatabaseInitService } from './database-init.service';
|
||||||
|
|
||||||
const ENTITIES = [
|
const ENTITIES = [
|
||||||
UserEntity,
|
UserEntity,
|
||||||
@@ -45,7 +46,7 @@ const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
|
|||||||
database: dbPath,
|
database: dbPath,
|
||||||
entities: ENTITIES,
|
entities: ENTITIES,
|
||||||
migrations: MIGRATIONS,
|
migrations: MIGRATIONS,
|
||||||
migrationsRun: true,
|
migrationsRun: false,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
};
|
};
|
||||||
@@ -53,16 +54,9 @@ const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
|
|||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature(ENTITIES),
|
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);
|
private readonly logger = new Logger(DatabaseModule.name);
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
|
||||||
|
|
||||||
onModuleInit(): void {
|
|
||||||
const dbPath = this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
|
|
||||||
if (dbPath === ':memory:' || dbPath.startsWith('file:')) return;
|
|
||||||
this.logger.log(`Database ready at ${dbPath}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+14
-2
@@ -11,6 +11,8 @@ import * as path from 'path';
|
|||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { SpaFallbackMiddleware } from './frontend/spa.controller';
|
import { SpaFallbackMiddleware } from './frontend/spa.controller';
|
||||||
|
import { DatabaseInitService } from './database/database-init.service';
|
||||||
|
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
@@ -31,6 +33,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
await app.get(DatabaseInitService).init();
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
@@ -58,9 +62,19 @@ async function bootstrap(): Promise<void> {
|
|||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
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(cookieParser());
|
||||||
app.use(express.json({ limit: bodyLimit }));
|
app.use(express.json({ limit: bodyLimit }));
|
||||||
app.use(express.urlencoded({ extended: true, 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.use('/uploads', express.static(uploadDir));
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
@@ -77,7 +91,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
jsonDocumentUrl: 'api/docs-json',
|
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)) {
|
if (fs.existsSync(frontendDist)) {
|
||||||
app.use(express.static(frontendDist, { index: false, fallthrough: true }));
|
app.use(express.static(frontendDist, { index: false, fallthrough: true }));
|
||||||
const browserDir = path.join(frontendDist, 'browser');
|
const browserDir = path.join(frontendDist, 'browser');
|
||||||
@@ -86,7 +99,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA fallback as plain express middleware registered LAST so /api, /uploads, and asset static all win first.
|
|
||||||
const spa = new SpaFallbackMiddleware(config);
|
const spa = new SpaFallbackMiddleware(config);
|
||||||
app.use((req, res, next) => spa.use(req as any, res as any, next));
|
app.use((req, res, next) => spa.use(req as any, res as any, next));
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,29 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { UserRole } from '../../database/entities/user.entity';
|
import { UserRole } from '../../database/entities/user.entity';
|
||||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||||
import { Roles } from '../../common/decorators/roles.decorator';
|
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';
|
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')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('api/v1/admin')
|
@Controller('api/v1/admin')
|
||||||
@@ -24,25 +39,35 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
@ApiOperation({ summary: 'List all users (admin only)' })
|
@ApiOperation({ summary: 'List all users (admin only)' })
|
||||||
list() {
|
list(
|
||||||
return this.admin.listUsers();
|
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
|
||||||
|
query: { limit?: number; cursor?: string; role?: UserRole },
|
||||||
|
) {
|
||||||
|
return this.admin.listUsers(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('users/:id')
|
@Patch('users/:id')
|
||||||
@ApiOperation({ summary: 'Update a user role (admin only)' })
|
@ApiOperation({ summary: 'Update a user role (admin only)' })
|
||||||
update(@Param('id') id: string, @Body() body: { role: UserRole }) {
|
update(
|
||||||
return this.admin.updateUserRole(id, body.role);
|
@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')
|
@Delete('users/:id')
|
||||||
@ApiOperation({ summary: 'Delete a user (admin only)' })
|
@ApiOperation({ summary: 'Delete a user (admin only)' })
|
||||||
async remove(@Param('id') id: string): Promise<void> {
|
async remove(
|
||||||
await this.admin.deleteUser(id);
|
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await this.admin.deleteUser(params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('users')
|
@Post('users')
|
||||||
@ApiOperation({ summary: 'Create a user (admin only)' })
|
@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);
|
return this.admin.createUser(body.username, body.password, body.role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,35 @@ import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { ApiError } from '../../common/errors/api-error';
|
import { ApiError } from '../../common/errors/api-error';
|
||||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
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()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listUsers(): Promise<UserEntity[]> {
|
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
|
||||||
return this.usersService.listAll();
|
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<UserEntity> {
|
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
|
||||||
@@ -24,7 +43,6 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
|
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
|
||||||
const repo = manager.getRepository(UserEntity);
|
|
||||||
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
||||||
if (!user) throw ApiError.notFound('User not found');
|
if (!user) throw ApiError.notFound('User not found');
|
||||||
user.role = role;
|
user.role = role;
|
||||||
@@ -36,13 +54,13 @@ export class AdminService {
|
|||||||
async deleteUser(targetId: string): Promise<void> {
|
async deleteUser(targetId: string): Promise<void> {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await this.usersService.enforceLastAdminInvariant(manager, targetId);
|
await this.usersService.enforceLastAdminInvariant(manager, targetId);
|
||||||
const repo = manager.getRepository(UserEntity);
|
const res = await manager.delete(UserEntity, { id: targetId });
|
||||||
const res = await repo.delete({ id: targetId });
|
|
||||||
if (!res.affected) throw ApiError.notFound('User not found');
|
if (!res.affected) throw ApiError.notFound('User not found');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
||||||
|
validatePassword(password, this.config);
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const existing = await manager.findOne(UserEntity, { where: { username } });
|
const existing = await manager.findOne(UserEntity, { where: { username } });
|
||||||
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
|
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
|
||||||
|
|||||||
@@ -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<typeof CreateUserDtoSchema>;
|
||||||
|
|
||||||
|
export const UpdateUserRoleDtoSchema = z.object({
|
||||||
|
role: z.enum(['admin', 'player']),
|
||||||
|
});
|
||||||
|
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
|
||||||
|
|
||||||
|
export const UserIdParamDtoSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
export type UserIdParamDto = z.infer<typeof UserIdParamDtoSchema>;
|
||||||
|
|
||||||
|
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<typeof ListUsersQueryDtoSchema>;
|
||||||
@@ -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<string>('UPLOAD_DIR', '/data/hipctf/uploads'));
|
||||||
|
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -5,11 +5,15 @@ process.env.FRONTEND_DIST = './frontend/dist';
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
import { HttpAdapterHost } from '@nestjs/core';
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { CookieAccessInfo } from 'cookiejar';
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
import { AppModule } from '../../backend/src/app.module';
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
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', () => {
|
describe('Admin route protection', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@@ -19,10 +23,14 @@ describe('Admin route protection', () => {
|
|||||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app.use(cookieParser());
|
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 }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
await app.init();
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
const server = app.getHttpServer();
|
const server = app.getHttpServer();
|
||||||
await request(server).post('/api/v1/auth/register-first-admin')
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,22 +6,31 @@ import { Test } from '@nestjs/testing';
|
|||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
import { HttpAdapterHost } from '@nestjs/core';
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { CookieAccessInfo } from 'cookiejar';
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
import { AppModule } from '../../backend/src/app.module';
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
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', () => {
|
describe('Auth refresh rotation', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
app.use(cookieParser());
|
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 }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
await app.init();
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
const server = app.getHttpServer();
|
const server = app.getHttpServer();
|
||||||
await request(server).post('/api/v1/auth/register-first-admin')
|
await request(server).post('/api/v1/auth/register-first-admin')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HttpAdapterHost } from '@nestjs/core';
|
|||||||
import { AppModule } from '../../backend/src/app.module';
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
import { csrfClient, primeCsrf } from './csrf-client';
|
import { csrfClient, primeCsrf } from './csrf-client';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
|
||||||
describe('Bootstrap integration', () => {
|
describe('Bootstrap integration', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
@@ -19,6 +20,7 @@ describe('Bootstrap integration', () => {
|
|||||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
await app.init();
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
await app.get<DatabaseInitService>(DatabaseInitService).init();
|
||||||
|
}
|
||||||
@@ -6,9 +6,14 @@ import { RegistrationRateLimitService } from '../../backend/src/common/services/
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { HttpAdapterHost } from '@nestjs/core';
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { AppModule } from '../../backend/src/app.module';
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
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', () => {
|
describe('RegistrationRateLimitService', () => {
|
||||||
it('allows up to 10 per minute per IP', () => {
|
it('allows up to 10 per minute per IP', () => {
|
||||||
@@ -36,9 +41,14 @@ describe('register-first-admin registration rate limit (integration)', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
app = moduleRef.createNestApplication();
|
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);
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
await app.init();
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
limiter = app.get(RegistrationRateLimitService);
|
limiter = app.get(RegistrationRateLimitService);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +68,7 @@ describe('register-first-admin registration rate limit (integration)', () => {
|
|||||||
|
|
||||||
const server = app.getHttpServer();
|
const server = app.getHttpServer();
|
||||||
const agent = request.agent(server);
|
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 cookies: any = agent.jar.getCookies(require('cookiejar').CookieAccessInfo.All);
|
||||||
const csrf = cookies.find((c: any) => c.name === 'csrf');
|
const csrf = cookies.find((c: any) => c.name === 'csrf');
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,21 @@ import { ThemeLoaderService } from '../../backend/src/common/utils/theme-loader.
|
|||||||
|
|
||||||
describe('ThemeLoaderService', () => {
|
describe('ThemeLoaderService', () => {
|
||||||
const config = { get: jest.fn() } as any;
|
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', () => {
|
it('falls back to default and warns on unknown theme id', () => {
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-'));
|
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] } }));
|
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));
|
config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d));
|
||||||
const svc = new ThemeLoaderService(config);
|
const svc = new ThemeLoaderService(config, settings);
|
||||||
svc.onModuleInit();
|
return svc.onModuleInit().then(() => {
|
||||||
const t = svc.getTheme('does-not-exist');
|
const t = svc.getTheme('does-not-exist');
|
||||||
expect(t.id).toBe('classic');
|
expect(t.id).toBe('classic');
|
||||||
expect(t.tokens.primary).toBe('#000');
|
expect(t.tokens.primary).toBe('#000');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a valid theme by id', () => {
|
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] } };
|
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));
|
fs.writeFileSync(path.join(tmp, 'classic.json'), JSON.stringify(theme));
|
||||||
config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d));
|
config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d));
|
||||||
const svc = new ThemeLoaderService(config);
|
const svc = new ThemeLoaderService(config, settings);
|
||||||
svc.onModuleInit();
|
return svc.onModuleInit().then(() => {
|
||||||
const t = svc.getTheme('classic');
|
const t = svc.getTheme('classic');
|
||||||
expect(t.id).toBe('classic');
|
expect(t.id).toBe('classic');
|
||||||
expect(t.tokens.spacingScale).toEqual([4, 8]);
|
expect(t.tokens.spacingScale).toEqual([4, 8]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips invalid theme files', () => {
|
it('skips invalid theme files', () => {
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-'));
|
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
|
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));
|
config.get.mockImplementation((k: string, d: any) => (k === 'THEMES_DIR' ? tmp : d));
|
||||||
const svc = new ThemeLoaderService(config);
|
const svc = new ThemeLoaderService(config, settings);
|
||||||
svc.onModuleInit();
|
return svc.onModuleInit().then(() => {
|
||||||
// Falls back to builtins; classic should be findable via builtins
|
// Falls back to builtins; classic should be findable via builtins
|
||||||
const t = svc.getTheme('classic');
|
const t = svc.getTheme('classic');
|
||||||
expect(t).toBeDefined();
|
expect(t).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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\//);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,9 +19,11 @@ describe('ThemeLoaderService builtins', () => {
|
|||||||
|
|
||||||
it('falls back to default for unknown id', () => {
|
it('falls back to default for unknown id', () => {
|
||||||
const config = { get: jest.fn((k: string, d: any) => d) } as any;
|
const config = { get: jest.fn((k: string, d: any) => d) } as any;
|
||||||
const svc = new ThemeLoaderService(config);
|
const settings = { get: jest.fn().mockResolvedValue('classic') } as any;
|
||||||
svc.onModuleInit();
|
const svc = new ThemeLoaderService(config, settings);
|
||||||
const t = svc.getTheme('totally-unknown');
|
return svc.onModuleInit().then(() => {
|
||||||
expect(t.id).toBe('classic');
|
const t = svc.getTheme('totally-unknown');
|
||||||
|
expect(t.id).toBe('classic');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user