feat: Admin Area General Settings and Categories 1.11
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,9 @@ describe('DatabaseInitService', () => {
|
||||
|
||||
const categories = await ds.getRepository(CategoryEntity).find();
|
||||
const systemCats = categories.filter((c: any) => c.systemKey);
|
||||
expect(systemCats.length).toBeGreaterThanOrEqual(6);
|
||||
expect(systemCats.length).toBe(6);
|
||||
const systemKeys = systemCats.map((c: any) => c.systemKey).sort();
|
||||
expect(systemKeys).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']);
|
||||
|
||||
const settings = await ds.getRepository(SettingEntity).find();
|
||||
const keys = settings.map((s: any) => s.key);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1
|
||||
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 { DataSource } from 'typeorm';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
|
||||
@@ -27,6 +28,7 @@ describe('Migrations', () => {
|
||||
SeedSystemData1700000000100,
|
||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||
UpdateSystemCategoryKeys1700000000300,
|
||||
RepairCategorySchemaAndSystemCategories1700000000400,
|
||||
],
|
||||
migrationsRun: true,
|
||||
synchronize: false,
|
||||
@@ -48,12 +50,19 @@ describe('Migrations', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('seeds 6 system categories with unique system_keys', async () => {
|
||||
const cats = await dataSource.getRepository(CategoryEntity).find();
|
||||
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).toBeGreaterThanOrEqual(6);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user