feat: Admin Area General Settings and Categories 1.11

This commit is contained in:
OpenVelo Agent
2026-07-22 16:45:59 +00:00
parent b02369dd41
commit 9a6c805525
8 changed files with 431 additions and 91 deletions
@@ -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();
}
});
});
+3 -1
View File
@@ -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);
+12 -3
View File
@@ -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 () => {