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'); }); });