196 lines
8.0 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|