Files
HIPCTF2/tests/frontend/admin-challenges-form.spec.ts
T

317 lines
11 KiB
TypeScript

import '@angular/compiler';
import { FormBuilder } from '@angular/forms';
import {
buildChallengeFormGroup,
defaultMinimumPoints,
syncChallengeForm,
} from '../../frontend/src/app/features/admin/challenges/challenge-form-modal.component';
import {
ChallengeFormDefaults,
ChallengeFormFileItem,
ChallengeStagedFileUi,
buildInitialFiles,
defaultMinimumPoints as pureDefault,
deriveMinimumPoints,
firstErrorTab,
isValidIpOrHostname,
mapServerFieldErrors,
partitionFilesBySize,
prefillChallengeFormDefaults,
toCreateBody,
validateChallengeForm,
} from '../../frontend/src/app/features/admin/challenges/challenge-form.pure';
import { AdminChallengeDetail } from '../../frontend/src/app/core/services/admin.service';
function makeDetail(overrides: Partial<AdminChallengeDetail> = {}): AdminChallengeDetail {
return {
id: 'd1',
name: 'Sample',
descriptionMd: '# hi',
categoryId: 'cat-1',
categoryAbbreviation: 'CRY',
categoryIconPath: '/uploads/icons/CRY.png',
difficulty: 'MEDIUM',
initialPoints: 200,
minimumPoints: 100,
decaySolves: 5,
flag: 'flag{X}',
protocol: 'NC',
port: 1337,
ipAddress: '127.0.0.1',
enabled: true,
createdAt: '2026-07-22T00:00:00.000Z',
updatedAt: '2026-07-22T00:00:00.000Z',
files: [
{
id: 'f1',
originalFilename: 'manual.txt',
mimeType: 'text/plain',
sizeBytes: 42,
createdAt: '2026-07-22T00:00:00.000Z',
},
],
...overrides,
};
}
function makeDefaults(overrides: Partial<ChallengeFormDefaults> = {}): ChallengeFormDefaults {
return {
name: 'X',
descriptionMd: 'd',
categoryId: 'cat-1',
difficulty: 'MEDIUM',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
...overrides,
};
}
describe('pure: challenge form helpers', () => {
it('defaultMinimumPoints returns half of the initial points, floored, with a minimum of 1', () => {
expect(defaultMinimumPoints(100)).toBe(50);
expect(defaultMinimumPoints(1)).toBe(1);
expect(defaultMinimumPoints(3)).toBe(1);
expect(pureDefault(99)).toBe(49);
});
it('deriveMinimumPoints follows the auto-derived rule until the minimum is touched, then preserves the value', () => {
expect(deriveMinimumPoints(200, 100, 100, false)).toBe(100); // touched: kept
expect(deriveMinimumPoints(200, 50, 100, false)).toBe(100); // not touched, previous matched default
expect(deriveMinimumPoints(50, 50, 100, false)).toBe(25); // auto-derives because new initial
});
it('isValidIpOrHostname accepts IPv4, IPv6, hostnames and rejects garbage', () => {
expect(isValidIpOrHostname('127.0.0.1')).toBe(true);
expect(isValidIpOrHostname('::1')).toBe(true);
expect(isValidIpOrHostname('2001:db8::1')).toBe(true);
expect(isValidIpOrHostname('example.com')).toBe(true);
expect(isValidIpOrHostname('')).toBe(true);
expect(isValidIpOrHostname('not a host')).toBe(false);
expect(isValidIpOrHostname('-bad.com')).toBe(false);
});
it('validateChallengeForm flags missing name, min > initial, NC without port, invalid IP', () => {
const errs = validateChallengeForm(
makeDefaults({
name: '',
descriptionMd: '',
categoryId: '',
flag: '',
protocol: 'NC',
port: null,
initialPoints: 100,
minimumPoints: 200,
decaySolves: 0,
ipAddress: 'bad value',
}),
);
expect(errs.name).toBeDefined();
expect(errs.descriptionMd).toBeDefined();
expect(errs.categoryId).toBeDefined();
expect(errs.minimumPoints).toBeDefined();
expect(errs.flag).toBeDefined();
expect(errs.port).toBeDefined();
expect(errs.decaySolves).toBeDefined();
expect(errs.ipAddress).toBeDefined();
});
it('validateChallengeForm accepts a well-formed WEB challenge with optional port', () => {
const errs = validateChallengeForm(makeDefaults());
expect(errs).toEqual({});
});
it('mapServerFieldErrors flattens a Zod details array into per-field messages', () => {
const errs = mapServerFieldErrors([
{ path: 'name', message: 'taken' },
{ path: 'port', message: 'required' },
]);
expect(errs.name).toBe('taken');
expect(errs.port).toBe('required');
});
it('prefillChallengeFormDefaults hydrates every field from the detail', () => {
const d = makeDetail({ minimumPoints: 75, protocol: 'WEB', port: 443 });
const out = prefillChallengeFormDefaults(d);
expect(out.name).toBe('Sample');
expect(out.minimumPoints).toBe(75);
expect(out.protocol).toBe('WEB');
expect(out.port).toBe(443);
});
it('toCreateBody omits WEB port when null and keeps NC port', () => {
const web = toCreateBody(makeDefaults({ protocol: 'WEB', port: null }), []);
expect(web.port).toBeNull();
const nc = toCreateBody(makeDefaults({ protocol: 'NC', port: 1337 }), []);
expect(nc.port).toBe(1337);
});
it('toCreateBody maps staged files into the manifest, including removal markers', () => {
const files: ChallengeFormFileItem[] = [
{ id: 'f1', originalFilename: '', mimeType: '', sizeBytes: 0, remove: true },
{
stagedToken: 'tok',
originalFilename: 'a.txt',
mimeType: 'text/plain',
sizeBytes: 5,
},
];
const out = toCreateBody(makeDefaults(), files);
expect(out.files?.length).toBe(2);
expect(out.files?.[0].remove).toBe(true);
expect(out.files?.[1].stagedToken).toBe('tok');
});
it('toCreateBody produces a top-level payload (no nested body envelope) for HTTP transport', () => {
const out = toCreateBody(makeDefaults(), []);
const requiredTopLevel = [
'name',
'descriptionMd',
'categoryId',
'difficulty',
'initialPoints',
'minimumPoints',
'decaySolves',
'flag',
'protocol',
];
for (const k of requiredTopLevel) {
expect((out as any)[k]).toBeDefined();
expect((out as any).body).toBeUndefined();
}
});
it('buildInitialFiles hydrates a UI list from detail.files', () => {
const list: ChallengeStagedFileUi[] = buildInitialFiles(makeDetail());
expect(list.length).toBe(1);
expect(list[0].kind).toBe('existing');
expect(list[0].id).toBe('f1');
});
});
describe('syncChallengeForm', () => {
it('populates every control from the detail and marks them pristine', () => {
const form = buildChallengeFormGroup(new FormBuilder());
const out = syncChallengeForm(form, 'edit', makeDetail());
expect(form.controls.name.value).toBe('Sample');
expect(form.controls.initialPoints.value).toBe(200);
expect(form.controls.minimumPoints.value).toBe(100);
expect(form.controls.flag.value).toBe('flag{X}');
expect(form.controls.protocol.value).toBe('NC');
expect(form.controls.port.value).toBe(1337);
expect(form.controls.ipAddress.value).toBe('127.0.0.1');
expect(form.controls.name.pristine).toBe(true);
expect(out.minimumPoints).toBe(100);
});
it('resets every control when switching to create mode with null detail', () => {
const form = buildChallengeFormGroup(new FormBuilder());
syncChallengeForm(form, 'edit', makeDetail());
const out = syncChallengeForm(form, 'create', null);
expect(form.controls.name.value).toBe('');
expect(form.controls.initialPoints.value).toBe(100);
expect(form.controls.minimumPoints.value).toBe(50);
expect(out.protocol).toBe('WEB');
});
});
class StubAdminService {
stageChallengeFile = jest.fn(async (file: File) => ({
token: `tok-${file.name}`,
publicUrl: `/uploads/challenges/${file.name}`,
originalFilename: file.name,
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
}));
discardChallengeFile = jest.fn(async () => undefined);
createChallenge = jest.fn(async () => undefined);
updateChallenge = jest.fn(async () => undefined);
getChallengeDetail = jest.fn(async () => null);
}
describe('challenge form modal: regression Job 901 (pure)', () => {
it('firstErrorTab maps port errors to the connection tab', () => {
expect(firstErrorTab({ port: 'Port is required for NC' })).toBe('connection');
expect(firstErrorTab({ ipAddress: 'bad' })).toBe('connection');
expect(firstErrorTab({ protocol: 'bad' })).toBe('connection');
expect(firstErrorTab({ name: 'required' })).toBe('general');
expect(firstErrorTab({ flag: 'required' })).toBe('general');
expect(firstErrorTab({})).toBeNull();
});
it('Case 3: blank NC port triggers validateChallengeForm port error', () => {
const errs = validateChallengeForm(
makeDefaults({
protocol: 'NC',
port: null,
}),
);
expect(errs.port).toBe('Port is required for NC');
});
it('Case 3: port FormControl has a pattern validator that rejects non-digits', () => {
const form = buildChallengeFormGroup(new FormBuilder());
form.controls.port.setValue('12a3' as unknown as number);
form.controls.port.markAsTouched();
expect(form.controls.port.errors?.['pattern']).toBeDefined();
});
it('Case 4: validateChallengeForm rejects blank NC port regardless of blank IP', () => {
const errs = validateChallengeForm(
makeDefaults({
protocol: 'NC',
port: null,
ipAddress: '',
}),
);
expect(errs.port).toBe('Port is required for NC');
expect(errs.ipAddress).toBeUndefined();
});
it('Case 5: oversize file is rejected by partitionFilesBySize', () => {
const small = new File([new Uint8Array(100)], 'ok.txt', { type: 'text/plain' });
const big = new File([new Uint8Array(2048)], 'big.bin', { type: 'application/octet-stream' });
const out = partitionFilesBySize([small, big], 1024);
expect(out.accepted.length).toBe(1);
expect(out.accepted[0].file).toBe(small);
expect(out.rejected.length).toBe(1);
expect(out.rejected[0].file).toBe(big);
expect(out.rejected[0].reason).toContain('big.bin');
expect(out.rejected[0].reason).toContain('global size limit');
});
it('Case 6: drop-zone partitioning routes valid files to be staged', async () => {
const stub = new StubAdminService();
const small = new File([new Uint8Array(10)], 'dropped.txt', { type: 'text/plain' });
const dt = { files: [small], dropEffect: 'copy' } as unknown as DataTransfer;
const ev = { preventDefault: jest.fn(), dataTransfer: dt } as unknown as DragEvent;
expect(ev.dataTransfer?.files.length).toBe(1);
const outcome = partitionFilesBySize(
Array.from(ev.dataTransfer!.files),
50 * 1024 * 1024,
);
expect(outcome.accepted.length).toBe(1);
expect(outcome.rejected.length).toBe(0);
expect(outcome.accepted[0].file.name).toBe('dropped.txt');
const staged = await stub.stageChallengeFile(outcome.accepted[0].file);
expect(staged.token).toBe('tok-dropped.txt');
});
it('Case 6: drop-zone partitioning rejects oversize files without network call', () => {
const stub = new StubAdminService();
const big = new File([new Uint8Array(2048)], 'big.bin', { type: 'application/octet-stream' });
const dt = { files: [big], dropEffect: 'copy' } as unknown as DataTransfer;
const outcome = partitionFilesBySize(
Array.from(dt.files),
1024,
);
expect(outcome.accepted.length).toBe(0);
expect(outcome.rejected.length).toBe(1);
expect(stub.stageChallengeFile).not.toHaveBeenCalled();
});
});