272 lines
12 KiB
TypeScript
272 lines
12 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 { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
|
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
|
|
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
|
|
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
|
|
import { SeedSampleChallenges1700000000600 } from '../../backend/src/database/migrations/1700000000600-SeedSampleChallenges';
|
|
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,
|
|
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
|
UpdateSystemCategoryKeys1700000000300,
|
|
RepairCategorySchemaAndSystemCategories1700000000400,
|
|
UpgradeChallengeAdminSchema1700000000500,
|
|
SeedSampleChallenges1700000000600,
|
|
],
|
|
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 exactly six canonical system categories with unique keys', async () => {
|
|
const cats: any[] = await dataSource.getRepository(CategoryEntity).find();
|
|
const systemCats = cats.filter((c) => c.systemKey);
|
|
expect(systemCats.length).toBe(6);
|
|
const keys = systemCats.map((c) => c.systemKey);
|
|
expect(new Set(keys).size).toBe(keys.length);
|
|
expect([...keys].sort()).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']);
|
|
for (const row of systemCats) {
|
|
expect(typeof row.iconPath).toBe('string');
|
|
expect(row.iconPath.length).toBeGreaterThan(0);
|
|
expect(typeof row.description).toBe('string');
|
|
expect(row.description.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
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('keeps ON DELETE RESTRICT on solve.user_id (Job 861)', async () => {
|
|
const fks = await dataSource.query("PRAGMA foreign_key_list('solve')");
|
|
const userFk = fks.find((f: any) => f.table === 'user');
|
|
expect(userFk).toBeDefined();
|
|
expect(userFk.on_delete).toBe('RESTRICT');
|
|
});
|
|
|
|
it('creates the category table with created_at and updated_at columns (Job 889)', async () => {
|
|
const cols: any[] = await dataSource.query(`PRAGMA table_info("category")`);
|
|
const names = new Set(cols.map((c: any) => c.name));
|
|
expect(names.has('created_at')).toBe(true);
|
|
expect(names.has('updated_at')).toBe(true);
|
|
});
|
|
|
|
it('lists categories without a "no such column: c.created_at" error (Job 889)', async () => {
|
|
const rows = await dataSource
|
|
.getRepository(CategoryEntity)
|
|
.createQueryBuilder('c')
|
|
.orderBy('LOWER(c.abbreviation)', 'ASC')
|
|
.addOrderBy('c.abbreviation', 'ASC')
|
|
.getMany();
|
|
expect(Array.isArray(rows)).toBe(true);
|
|
expect(rows.length).toBeGreaterThanOrEqual(6);
|
|
});
|
|
|
|
it('upgrades challenge to canonical enums and adds enabled/updated_at columns (Job 861)', async () => {
|
|
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge")`);
|
|
const names = new Set(cols.map((c) => c.name));
|
|
expect(names.has('enabled')).toBe(true);
|
|
expect(names.has('updated_at')).toBe(true);
|
|
expect(names.has('description_md')).toBe(true);
|
|
});
|
|
|
|
it('enforces unique lower-cased challenge names (Job 861)', async () => {
|
|
const indexes = await dataSource.query(`SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='challenge'`);
|
|
const uq = indexes.find((i: any) => /UNIQUE.*LOWER\(\s*(?:"name")\s*\)/i.test(i.sql || ''));
|
|
expect(uq).toBeDefined();
|
|
});
|
|
|
|
it('cascade-deletes challenge_file rows when challenge is deleted (Job 861)', async () => {
|
|
const fks = await dataSource.query(`PRAGMA foreign_key_list('challenge_file')`);
|
|
const cascade = fks.find((f: any) => f.on_delete === 'CASCADE');
|
|
expect(cascade).toBeDefined();
|
|
});
|
|
|
|
it('cascade-deletes solve rows when challenge is deleted (Job 861)', async () => {
|
|
const fks = await dataSource.query(`PRAGMA foreign_key_list('solve')`);
|
|
const cascade = fks.find((f: any) => f.on_delete === 'CASCADE');
|
|
expect(cascade).toBeDefined();
|
|
});
|
|
|
|
it('still enforces UNIQUE(challenge_id,user_id) on solve (Job 861)', 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('adds is_first/is_second/is_third columns to solve (Job 861)', async () => {
|
|
const cols: any[] = await dataSource.query(`PRAGMA table_info("solve")`);
|
|
const names = new Set(cols.map((c) => c.name));
|
|
expect(names.has('is_first')).toBe(true);
|
|
expect(names.has('is_second')).toBe(true);
|
|
expect(names.has('is_third')).toBe(true);
|
|
});
|
|
|
|
it('adds stored_filename/mime_type/size_bytes/created_at to challenge_file (Job 861)', async () => {
|
|
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge_file")`);
|
|
const names = new Set(cols.map((c: any) => c.name));
|
|
expect(names.has('stored_filename')).toBe(true);
|
|
expect(names.has('mime_type')).toBe(true);
|
|
expect(names.has('size_bytes')).toBe(true);
|
|
expect(names.has('created_at')).toBe(true);
|
|
});
|
|
|
|
it('seeds sample challenges on a fresh database (Job 906)', async () => {
|
|
const repo = dataSource.getRepository(ChallengeEntity);
|
|
const rows = await repo.find();
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
const names = new Set(rows.map((r) => String((r as any).name).toLowerCase()));
|
|
for (const expected of [
|
|
'alpha cipher',
|
|
'beta cipher',
|
|
'zeta key',
|
|
'web welcome',
|
|
'mobile mayhem',
|
|
'hardware hello',
|
|
'markdown mystery',
|
|
'reverse ranger',
|
|
]) {
|
|
expect(names.has(expected)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('seeded sample challenges are enabled, schema-compatible, and point at the right categories (Job 906)', async () => {
|
|
const cats: any[] = await dataSource.getRepository(CategoryEntity).find();
|
|
const idByKey = new Map(cats.filter((c) => c.systemKey).map((c) => [String(c.systemKey), String(c.id)]));
|
|
const rows = await dataSource.getRepository(ChallengeEntity).find();
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
for (const row of rows) {
|
|
expect(Boolean((row as any).enabled)).toBe(true);
|
|
expect(['LOW', 'MEDIUM', 'HIGH']).toContain((row as any).difficulty);
|
|
expect(Number((row as any).initialPoints)).toBeGreaterThanOrEqual(Number((row as any).minimumPoints));
|
|
expect(typeof (row as any).flag).toBe('string');
|
|
expect(((row as any).flag as string).length).toBeGreaterThan(0);
|
|
expect(idByKey.values()).toContain((row as any).categoryId);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('SeedSampleChallenges down() scope (Job 906)', () => {
|
|
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,
|
|
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
|
UpdateSystemCategoryKeys1700000000300,
|
|
RepairCategorySchemaAndSystemCategories1700000000400,
|
|
UpgradeChallengeAdminSchema1700000000500,
|
|
],
|
|
migrationsRun: true,
|
|
synchronize: false,
|
|
logging: false,
|
|
});
|
|
await dataSource.initialize();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await dataSource.destroy();
|
|
});
|
|
|
|
it('removes only the seeded sample names and leaves unrelated rows untouched', async () => {
|
|
const cat: any = await dataSource.getRepository(CategoryEntity).findOne({ where: { systemKey: 'CRY' } });
|
|
expect(cat).toBeDefined();
|
|
|
|
// The seed migration was deliberately excluded above so we can insert
|
|
// both a sample-name row and an unrelated manual row by hand and verify
|
|
// down() targets only the sample names.
|
|
await dataSource.query(
|
|
`INSERT INTO "challenge" (
|
|
"id","name","description_md","category_id","difficulty",
|
|
"initial_points","minimum_points","decay_solves","flag","protocol",
|
|
"port","ip_address","enabled","created_at","updated_at"
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
|
|
[
|
|
'00000000-0000-0000-0000-000000000a01',
|
|
'Alpha Cipher',
|
|
'sample',
|
|
cat.id,
|
|
'LOW',
|
|
200, 100, 5, 'flag{alpha}', 'WEB', null, '', 1,
|
|
],
|
|
);
|
|
await dataSource.query(
|
|
`INSERT INTO "challenge" (
|
|
"id","name","description_md","category_id","difficulty",
|
|
"initial_points","minimum_points","decay_solves","flag","protocol",
|
|
"port","ip_address","enabled","created_at","updated_at"
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
|
|
[
|
|
'00000000-0000-0000-0000-000000000a02',
|
|
'Other',
|
|
'manual',
|
|
cat.id,
|
|
'MEDIUM',
|
|
100, 50, 10, 'flag{other}', 'WEB', null, '', 1,
|
|
],
|
|
);
|
|
|
|
const qr = dataSource.createQueryRunner();
|
|
try {
|
|
const seed = new SeedSampleChallenges1700000000600();
|
|
await seed.down(qr);
|
|
} finally {
|
|
await qr.release();
|
|
}
|
|
|
|
const remaining: any[] = await dataSource.query(`SELECT name FROM "challenge" ORDER BY name`);
|
|
const names = remaining.map((r) => String(r.name));
|
|
expect(names).toContain('Other');
|
|
expect(names).not.toContain('Alpha Cipher');
|
|
});
|
|
});
|