af3c24275d
- Backend: NestJS 10 + TypeORM (better-sqlite3) feature-modular layout.
- Entities: user, setting, category, challenge, challenge_file, solve,
refresh_token, blog_post. Auto-run migrations + idempotent 6-system-
category seed on startup.
- Argon2id password hashing with policy check; JWT access + rotating
refresh tokens (HttpOnly cookie); CSRF middleware (SameSite + custom
X-CSRF-Token header); global JWT auth guard with @Public() opt-out;
per-IP login backoff + per-IP registration rate limit.
- Endpoints: auth (login/refresh/logout/csrf), users (first-admin
registration), system (bootstrap/event status/SSE), admin (guarded
user CRUD with last-admin invariant), frontend module (uploads +
SPA fallback).
- Security: helmet+CSP+HSTS-gated-by-TLS, CORS allowlist, structured
global exception filter, Zod request validation pipes, OpenAPI 3.1
served at /api/docs and /api/docs-json, 10 canonical themes under
backend/themes/.
- Frontend: Angular 17 standalone components, lazy-loaded feature routes,
signals, functional HttpInterceptorFn (csrf + auth), functional
CanActivateFn auth guard, HttpOnly-cookie-based auth service.
- Tests: Jest + supertest, 46 tests across 13 suites covering
migrations, env schema, theme loader, event status, login backoff,
registration rate limit, ApiError shape, bootstrap integration,
auth refresh rotation, admin guard, last-admin invariant, SSE flat
payloads. Single-command runner: `npm test`.
69 lines
3.2 KiB
TypeScript
69 lines
3.2 KiB
TypeScript
import * as path from 'path';
|
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema';
|
|
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
|
|
import { DataSource } from 'typeorm';
|
|
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
|
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
|
|
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
|
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
|
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
|
|
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
|
|
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
|
|
import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity';
|
|
|
|
describe('Migrations', () => {
|
|
let dataSource: DataSource;
|
|
|
|
beforeAll(async () => {
|
|
dataSource = new DataSource({
|
|
type: 'better-sqlite3',
|
|
database: ':memory:',
|
|
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity],
|
|
migrations: [InitSchema1700000000000, SeedSystemData1700000000100],
|
|
migrationsRun: true,
|
|
synchronize: false,
|
|
logging: false,
|
|
});
|
|
await dataSource.initialize();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await dataSource.destroy();
|
|
});
|
|
|
|
it('creates all required tables', async () => {
|
|
const tables = await dataSource.query("SELECT name FROM sqlite_master WHERE type='table'");
|
|
const names = tables.map((t: any) => t.name);
|
|
expect(names).toEqual(expect.arrayContaining([
|
|
'user', 'setting', 'category', 'challenge', 'challenge_file',
|
|
'solve', 'refresh_token', 'blog_post',
|
|
]));
|
|
});
|
|
|
|
it('seeds 6 system categories with unique system_keys', async () => {
|
|
const cats = await dataSource.getRepository(CategoryEntity).find();
|
|
const systemCats = cats.filter((c) => c.systemKey);
|
|
expect(systemCats.length).toBeGreaterThanOrEqual(6);
|
|
const keys = systemCats.map((c) => c.systemKey);
|
|
expect(new Set(keys).size).toBe(keys.length);
|
|
});
|
|
|
|
it('seeds default settings', async () => {
|
|
const settings = await dataSource.getRepository(SettingEntity).find();
|
|
const keys = settings.map((s) => s.key);
|
|
expect(keys).toEqual(expect.arrayContaining(['pageTitle', 'themeKey', 'eventStartUtc', 'eventEndUtc']));
|
|
});
|
|
|
|
it('enforces UNIQUE(challengeId, userId) on solve', async () => {
|
|
const indexes = await dataSource.query("SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='solve'");
|
|
const uq = indexes.find((i: any) => /UNIQUE.*challenge_id.*user_id/i.test(i.sql || ''));
|
|
expect(uq).toBeDefined();
|
|
});
|
|
|
|
it('enforces ON DELETE RESTRICT FK on solve', async () => {
|
|
const fks = await dataSource.query("PRAGMA foreign_key_list('solve')");
|
|
const restrict = fks.filter((f: any) => f.on_delete === 'RESTRICT');
|
|
expect(restrict.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
}); |