Files
HIPCTF2/tests/backend/category-repair-migration.spec.ts
T

196 lines
8.0 KiB
TypeScript

import { DataSource, QueryRunner } from 'typeorm';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import {
RepairCategorySchemaAndSystemCategories1700000000400,
CANONICAL_SYSTEM_CATEGORY_KEYS,
CANONICAL_SYSTEM_CATEGORIES,
} from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { v4 as uuid } from 'uuid';
async function bootstrapLegacyCategoryTable(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`
CREATE TABLE "category" (
"id" TEXT PRIMARY KEY,
"system_key" TEXT,
"name" TEXT NOT NULL,
"abbreviation" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"icon_path" TEXT NOT NULL DEFAULT ''
);
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`,
);
}
async function seedLegacyRows(queryRunner: QueryRunner): Promise<void> {
const legacy = [
{ key: 'CRY', name: 'Cryptography', abbr: 'CRY', desc: 'Crypto challenges', icon: '/uploads/icons/crypto.svg' },
{ key: 'FOR', name: 'Forensics', abbr: 'FOR', desc: 'Forensics challenges', icon: '/uploads/icons/forensics.svg' },
{ key: 'MSC', name: 'Misc', abbr: 'MSC', desc: 'Miscellaneous', icon: '/uploads/icons/misc.svg' },
{ key: 'OSI', name: 'OSINT', abbr: 'OSI', desc: 'Open-source intelligence', icon: '/uploads/icons/osint.svg' },
{ key: 'PWN', name: 'Pwn', abbr: 'PWN', desc: 'Binary exploitation', icon: '/uploads/icons/pwn.svg' },
{ key: 'WEB', name: 'Web', abbr: 'WEB', desc: 'Web exploitation', icon: '/uploads/icons/web.svg' },
];
for (const r of legacy) {
await queryRunner.query(
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?,?,?,?,?,?)`,
[uuid(), r.key, r.name, r.abbr, r.desc, r.icon],
);
}
}
async function createDataSource(): Promise<DataSource> {
return new DataSource({
type: 'better-sqlite3',
database: ':memory:',
entities: [CategoryEntity],
migrations: [RepairCategorySchemaAndSystemCategories1700000000400],
migrationsRun: false,
synchronize: false,
logging: false,
});
}
function expectCanonicalContents(rows: any[]): void {
const sortedByLowerAbbrev = [...rows].sort((a, b) =>
a.abbreviation.toLowerCase().localeCompare(b.abbreviation.toLowerCase()),
);
const abbrevs = sortedByLowerAbbrev.map((r) => r.abbreviation);
expect(abbrevs).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']);
const byKey = new Map(sortedByLowerAbbrev.map((r) => [r.systemKey ?? r.system_key, r]));
expect(byKey.size).toBe(CANONICAL_SYSTEM_CATEGORY_KEYS.length);
for (const canonical of CANONICAL_SYSTEM_CATEGORIES) {
const row = byKey.get(canonical.key);
expect(row).toBeDefined();
expect(row.name).toBe(canonical.name);
expect(row.abbreviation).toBe(canonical.abbreviation);
expect(row.description).toBe(canonical.description);
expect(row.iconPath ?? row.icon_path).toBe(canonical.iconPath);
const createdAt = row.createdAt ?? row.created_at;
const updatedAt = row.updatedAt ?? row.updated_at;
expect(typeof createdAt).toBe('string');
expect(createdAt.length).toBeGreaterThan(0);
expect(typeof updatedAt).toBe('string');
expect(updatedAt.length).toBeGreaterThan(0);
}
}
describe('RepairCategorySchemaAndSystemCategories1700000000400', () => {
it('adds missing created_at/updated_at columns and reconciles canonical system rows', async () => {
const ds = await createDataSource();
await ds.initialize();
try {
const queryRunner = ds.createQueryRunner();
try {
await queryRunner.query(`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`);
await bootstrapLegacyCategoryTable(queryRunner);
await seedLegacyRows(queryRunner);
const beforeCols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
const beforeNames = new Set(beforeCols.map((c: any) => c.name));
expect(beforeNames.has('created_at')).toBe(false);
expect(beforeNames.has('updated_at')).toBe(false);
const migration = new RepairCategorySchemaAndSystemCategories1700000000400();
await migration.up(queryRunner);
const afterCols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
const afterNames = new Set(afterCols.map((c: any) => c.name));
expect(afterNames.has('created_at')).toBe(true);
expect(afterNames.has('updated_at')).toBe(true);
const rows: any[] = await queryRunner.query(
`SELECT system_key, name, abbreviation, description, icon_path, created_at, updated_at FROM "category" WHERE "system_key" IS NOT NULL`,
);
expectCanonicalContents(rows);
const repo = ds.getRepository(CategoryEntity);
const repoRows = await repo
.createQueryBuilder('c')
.orderBy('LOWER(c.abbreviation)', 'ASC')
.addOrderBy('c.abbreviation', 'ASC')
.getMany();
expectCanonicalContents(repoRows);
const hasFor = await queryRunner.query(`SELECT 1 FROM "category" WHERE "system_key" = 'FOR'`);
expect(hasFor.length).toBe(0);
const hasOsi = await queryRunner.query(`SELECT 1 FROM "category" WHERE "system_key" = 'OSI'`);
expect(hasOsi.length).toBe(0);
} finally {
await queryRunner.release();
}
} finally {
await ds.destroy();
}
});
it('is idempotent — running it twice leaves exactly the canonical six system rows', async () => {
const ds = await createDataSource();
await ds.initialize();
try {
const queryRunner = ds.createQueryRunner();
try {
await queryRunner.query(
`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`,
);
await bootstrapLegacyCategoryTable(queryRunner);
await seedLegacyRows(queryRunner);
const migration = new RepairCategorySchemaAndSystemCategories1700000000400();
await migration.up(queryRunner);
await migration.up(queryRunner);
const rows: any[] = await queryRunner.query(
`SELECT system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL ORDER BY abbreviation`,
);
const systemKeys = rows.map((r) => r.system_key);
const unique = new Set(systemKeys);
expect(rows.length).toBe(6);
expect(unique.size).toBe(6);
expect(systemKeys.sort()).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'].sort());
} finally {
await queryRunner.release();
}
} finally {
await ds.destroy();
}
});
it('preserves user-created rows (system_key IS NULL)', async () => {
const ds = await createDataSource();
await ds.initialize();
try {
const queryRunner = ds.createQueryRunner();
try {
await queryRunner.query(
`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`,
);
await bootstrapLegacyCategoryTable(queryRunner);
await queryRunner.query(
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?, NULL, ?, ?, ?, ?)`,
[uuid(), 'Community', 'CC', 'Community challenges', '/uploads/icons/community.svg'],
);
const migration = new RepairCategorySchemaAndSystemCategories1700000000400();
await migration.up(queryRunner);
const userRows: any[] = await queryRunner.query(
`SELECT name, abbreviation FROM "category" WHERE "system_key" IS NULL`,
);
expect(userRows.length).toBe(1);
expect(userRows[0].name).toBe('Community');
expect(userRows[0].abbreviation).toBe('CC');
} finally {
await queryRunner.release();
}
} finally {
await ds.destroy();
}
});
});