200 lines
6.9 KiB
TypeScript
200 lines
6.9 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,
|
|
isValidIpOrHostname,
|
|
mapServerFieldErrors,
|
|
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('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');
|
|
});
|
|
}); |