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 { return { id: 'd1', name: 'Sample', descriptionMd: '# hi', categoryId: '11111111-1111-4111-8111-111111111111', 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 { return { name: 'X', descriptionMd: 'd', categoryId: '11111111-1111-4111-8111-111111111111', 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(); }); });