feat: Admin Area System: Database Backup/Restore and Danger Zone 1.01

This commit is contained in:
OpenVelo Agent
2026-07-23 13:28:26 +00:00
parent 3a20f8ee3c
commit 2ecef2947f
4 changed files with 340 additions and 157 deletions
@@ -120,3 +120,239 @@ describe('Job 916: Admin restore commit body passes stagingId through', () => {
void ApiError;
});
});
describe('Job 918: Restore commit succeeds end-to-end with a real backup file', () => {
// Minimal valid archive shape used to drive RestoreService.stageArchive + commitRestore
// against a real file-backed SQLite database and uploads directory.
beforeEach(() => {
jest.resetModules();
delete process.env.SYSTEM_OP_RESTORE_STAGE_TTL_MS;
delete process.env.SYSTEM_OP_CONFIRM_TOKEN_TTL_MS;
delete process.env.SYSTEM_OP_STAGING_DIR;
delete process.env.UPLOAD_DIR;
delete process.env.DATABASE_PATH;
});
async function buildArchive(adminId: string, challengeCount: number, blogCount: number) {
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const tmp = fs.mkdtempSync(require('path').join(require('os').tmpdir(), 'hipctf-archive-'));
const dbPath = path.join(tmp, 'source.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);
CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT);
CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT);
CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
`);
db.prepare(`INSERT INTO "user" (id, username, role, passwordHash, status) VALUES (?, ?, 'admin', 'hash', 'enabled')`).run(adminId, 'admin');
db.prepare(`INSERT INTO "setting" (key, value) VALUES (?, ?)`).run('site_title', 'ARCHIVE');
for (let i = 0; i < challengeCount; i++) {
db.prepare(`INSERT INTO "challenge" (id, title) VALUES (?, ?)`).run(`c${i}`, `Archived ${i}`);
}
for (let i = 0; i < blogCount; i++) {
db.prepare(`INSERT INTO "blog_post" (id, title) VALUES (?, ?)`).run(`b${i}`, `Archived blog ${i}`);
}
const tables = {
user: db.prepare('SELECT * FROM "user"').all(),
setting: db.prepare('SELECT * FROM "setting"').all(),
challenge: db.prepare('SELECT * FROM "challenge"').all(),
blog_post: db.prepare('SELECT * FROM "blog_post"').all(),
};
const uploads = [
{
path: 'icons/replaced.png',
originalFilename: 'replaced.png',
mimeType: 'image/png',
sizeBytes: 4,
sha256: '',
dataBase64: Buffer.from('ARCH').toString('base64'),
},
];
uploads[0].sha256 = require('crypto').createHash('sha256').update(Buffer.from('ARCH')).digest('hex');
db.close();
const archive = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: new Date().toISOString(),
tables,
tableCounts: {
user: tables.user.length,
setting: tables.setting.length,
challenge: tables.challenge.length,
blog_post: tables.blog_post.length,
},
uploads,
};
return { archiveText: JSON.stringify(archive), cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
}
function makeLiveDbWithTrigger(dbPath: string): void {
const fs = require('fs');
fs.rmSync(dbPath, { recursive: true, force: true });
const Database = require('better-sqlite3');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);
CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT);
CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT);
INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('admin-live', 'admin', 'admin', 'hash', 'enabled');
INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('player-live', 'p1', 'player', 'hash', 'enabled');
INSERT INTO "setting" (key, value) VALUES ('site_title', 'MODIFIED TITLE');
INSERT INTO "challenge" (id, title) VALUES ('live-1', 'Live 1');
INSERT INTO "blog_post" (id, title) VALUES ('live-b1', 'Live blog');
CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
`);
db.close();
}
it('successfully swaps live DB and uploads for an archive that contains the live admin', async () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job918-'));
const dataDir = path.join(work, 'data');
const uploadDir = path.join(dataDir, 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
fs.mkdirSync(path.join(uploadDir, 'icons'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'icons', 'old.png'), Buffer.from('OLD'));
const dbPath = path.join(dataDir, 'db.sqlite');
makeLiveDbWithTrigger(dbPath);
process.env.DATABASE_PATH = dbPath;
process.env.UPLOAD_DIR = uploadDir;
process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging');
const { ConfigService } = require('@nestjs/config');
const config = new ConfigService({
DATABASE_PATH: dbPath,
UPLOAD_DIR: uploadDir,
SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'),
SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000,
SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000,
BODY_SIZE_LIMIT: '50mb',
});
const ttl = config.get('SYSTEM_OP_RESTORE_STAGE_TTL_MS');
expect(typeof ttl).toBe('number');
const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service');
const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service');
const fakeDataSource = {
query: jest.fn().mockResolvedValue([{ c: 1 }]),
};
const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService());
const { archiveText, cleanup } = await buildArchive('admin-live', 3, 2);
const staged = await restore.stageArchive(archiveText, 'admin-live');
expect(staged.stagingId).toBeTruthy();
const result = await restore.commitRestore(staged.stagingId);
expect(result.restoresPerformed).toBe(true);
const liveDb = new Database(dbPath, { readonly: true });
const counts = {
user: liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c,
challenge: liveDb.prepare(`SELECT COUNT(*) AS c FROM "challenge"`).get().c,
blog_post: liveDb.prepare(`SELECT COUNT(*) AS c FROM "blog_post"`).get().c,
};
const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value;
const adminCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE role='admin'`).get().c;
const triggerNames = liveDb
.prepare(`SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'trg_user_last_admin_%'`)
.all()
.map((r: any) => r.name)
.sort();
liveDb.close();
expect(counts).toEqual({ user: 1, challenge: 3, blog_post: 2 });
expect(title).toBe('ARCHIVE');
expect(adminCount).toBe(1);
expect(triggerNames).toEqual(['trg_user_last_admin_delete', 'trg_user_last_admin_update']);
expect(fs.existsSync(path.join(uploadDir, 'icons', 'replaced.png'))).toBe(true);
expect(fs.existsSync(path.join(uploadDir, 'icons', 'old.png'))).toBe(false);
expect(fs.readFileSync(path.join(uploadDir, 'icons', 'replaced.png')).toString()).toBe('ARCH');
cleanup();
fs.rmSync(work, { recursive: true, force: true });
});
it('rejects an archive with no admin user without swapping live data', async () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job918-noadmin-'));
const dataDir = path.join(work, 'data');
const uploadDir = path.join(dataDir, 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'keep.txt'), 'KEEP');
const dbPath = path.join(dataDir, 'db.sqlite');
makeLiveDbWithTrigger(dbPath);
process.env.DATABASE_PATH = dbPath;
process.env.UPLOAD_DIR = uploadDir;
process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging');
const { ConfigService } = require('@nestjs/config');
const config = new ConfigService({
DATABASE_PATH: dbPath,
UPLOAD_DIR: uploadDir,
SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'),
SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000,
SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000,
BODY_SIZE_LIMIT: '50mb',
});
const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service');
const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service');
const fakeDataSource = { query: jest.fn().mockResolvedValue([{ c: 1 }]) };
const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService());
const Database2 = require('better-sqlite3');
const db = new Database2(':memory:');
db.exec(`CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);`);
const archive = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: new Date().toISOString(),
tables: { user: db.prepare(`SELECT * FROM "user"`).all(), setting: [], challenge: [], blog_post: [] },
tableCounts: { user: 0, setting: 0, challenge: 0, blog_post: 0 },
uploads: [],
};
db.close();
const staged = await restore.stageArchive(JSON.stringify(archive), 'admin-live');
await expect(restore.commitRestore(staged.stagingId)).rejects.toMatchObject({
code: ERROR_CODES.SYSTEM_RESTORE_ROLLED_BACK,
});
const liveDb = new Database(dbPath, { readonly: true });
const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value;
const userCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c;
liveDb.close();
expect(title).toBe('MODIFIED TITLE');
expect(userCount).toBe(2);
expect(fs.readFileSync(path.join(uploadDir, 'keep.txt')).toString()).toBe('KEEP');
fs.rmSync(work, { recursive: true, force: true });
});
});