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:
OpenVelo Agent
2026-07-21 14:04:27 +00:00
parent af3c24275d
commit ac6c834525
23 changed files with 934 additions and 75 deletions
+65
View File
@@ -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);
});
});