feat: Admin Area System: Database Backup/Restore and Danger Zone 1.01
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user