AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone (#61)

This commit was merged in pull request #61.
This commit is contained in:
2026-07-23 12:10:41 +00:00
parent 6bac67fad7
commit 470ddd30c3
42 changed files with 3996 additions and 55 deletions
@@ -0,0 +1,129 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { getDataSourceToken } from '@nestjs/typeorm';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { validateEnv } from '../../backend/src/config/env.schema';
import { RestoreService } from '../../backend/src/modules/admin/system/restore.service';
import { FilesystemTransactionService } from '../../backend/src/modules/admin/system/filesystem-transaction.service';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity';
import { AdminOperationTokenEntity } from '../../backend/src/database/entities/admin-operation-token.entity';
describe('Job 871: RestoreService validation rejects malformed archives before mutation', () => {
let moduleRef: any;
let restore: RestoreService;
let dataSource: any;
beforeAll(async () => {
process.env.UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-restore-uploads-'));
moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [
UserEntity, SettingEntity, CategoryEntity, ChallengeEntity,
ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity,
AdminOperationTokenEntity,
],
synchronize: true,
}),
],
providers: [RestoreService, FilesystemTransactionService],
}).compile();
moduleRef.init();
dataSource = moduleRef.get(getDataSourceToken());
restore = moduleRef.get(RestoreService);
});
afterAll(async () => {
const dir = process.env.UPLOAD_DIR!;
if (fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
await moduleRef?.close();
});
it('rejects empty JSON', async () => {
await expect(restore.stageArchive('', 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('rejects wrong format', async () => {
const bad = JSON.stringify({ format: 'wrong', version: 1, exportedAt: 'x', tables: {}, uploads: [] });
await expect(restore.stageArchive(bad, 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('rejects base64 size mismatch', async () => {
const bad = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: 'x',
tables: {},
uploads: [
{
path: 'icons/mismatch.png',
originalFilename: 'm.png',
mimeType: 'image/png',
sizeBytes: 100,
dataBase64: Buffer.from('short').toString('base64'),
},
],
};
await expect(restore.stageArchive(JSON.stringify(bad), 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('rejects path traversal', async () => {
const data = Buffer.alloc(0).toString('base64');
const bad = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: 'x',
tables: {},
uploads: [
{
path: '../../../etc/passwd',
originalFilename: 'x',
mimeType: 'application/octet-stream',
sizeBytes: 0,
dataBase64: data,
},
],
};
await expect(restore.stageArchive(JSON.stringify(bad), 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('does not mutate live data after rejected validation', async () => {
const users = await dataSource.query('SELECT COUNT(*) AS c FROM user');
expect(Number((users as any[])[0]?.c ?? 0)).toBeGreaterThanOrEqual(0);
const stagedCount = (restore as any).stages?.size ?? 0;
expect(stagedCount).toBe(0);
});
});