Files
HIPCTF2/tests/frontend/admin-general-pure.spec.ts
T

329 lines
13 KiB
TypeScript

import {
datetimeMessage,
defaultChallengeAddressError,
defaultChallengeAddressMessage,
deriveEventState,
endAfterStartValidator,
eventEndFieldMessage,
isValidDefaultChallengeAddress,
isoDatetimeValidator,
normalizeDefaultChallengeAddress,
normalizePageTitle,
pageTitleError,
pageTitleMessage,
toDatetimeLocal,
toIsoUtc,
} from '../../frontend/src/app/features/admin/general.pure';
describe('deriveEventState', () => {
const start = '2026-01-01T00:00:00Z';
const end = '2026-01-02T00:00:00Z';
it('returns "unconfigured" when start or end is missing', () => {
expect(deriveEventState('', '')).toBe('unconfigured');
expect(deriveEventState(start, '')).toBe('unconfigured');
expect(deriveEventState('', end)).toBe('unconfigured');
});
it('returns "unconfigured" when dates cannot be parsed', () => {
expect(deriveEventState('not-a-date', end)).toBe('unconfigured');
});
it('returns "countdown" when now is before start', () => {
const s = new Date(Date.now() + 60_000).toISOString();
const e = new Date(Date.now() + 120_000).toISOString();
expect(deriveEventState(s, e)).toBe('countdown');
});
it('returns "running" when now is between start and end', () => {
const s = new Date(Date.now() - 60_000).toISOString();
const e = new Date(Date.now() + 60_000).toISOString();
expect(deriveEventState(s, e)).toBe('running');
});
it('returns "stopped" when now is after end', () => {
const s = new Date(Date.now() - 120_000).toISOString();
const e = new Date(Date.now() - 60_000).toISOString();
expect(deriveEventState(s, e)).toBe('stopped');
});
});
describe('endAfterStartValidator', () => {
const ctrl = (start: string, end: string) => ({
get: (k: string) =>
k === 'eventStartUtc'
? { value: start }
: k === 'eventEndUtc'
? { value: end }
: { value: '' },
});
it('returns null when either field is empty', () => {
expect(endAfterStartValidator(ctrl('', '2026-01-01T00:00:00Z'))).toBeNull();
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', ''))).toBeNull();
});
it('returns null when end is strictly after start', () => {
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'))).toBeNull();
});
it('returns { endBeforeStart: true } when end equals start', () => {
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'))).toEqual({ endBeforeStart: true });
});
it('returns { endBeforeStart: true } when end is before start', () => {
expect(endAfterStartValidator(ctrl('2026-01-02T00:00:00Z', '2026-01-01T00:00:00Z'))).toEqual({ endBeforeStart: true });
});
});
describe('datetime helpers', () => {
it('toDatetimeLocal formats an ISO timestamp with UTC components', () => {
expect(toDatetimeLocal('2026-01-02T03:04:00Z')).toBe('2026-01-02T03:04');
});
it('toDatetimeLocal returns empty string for invalid input', () => {
expect(toDatetimeLocal('')).toBe('');
expect(toDatetimeLocal('garbage')).toBe('');
});
it('toIsoUtc converts a local datetime back to UTC ISO', () => {
const iso = toIsoUtc('2026-01-02T03:04');
expect(iso).toMatch(/^2026-01-02T03:04:00\.000Z$/);
});
it('toIsoUtc returns input verbatim when empty', () => {
expect(toIsoUtc('')).toBe('');
});
it('toIsoUtc interprets datetime-local components as UTC regardless of browser timezone', () => {
expect(toIsoUtc('2027-03-15T08:30')).toBe('2027-03-15T08:30:00.000Z');
expect(toIsoUtc('2027-03-17T16:45')).toBe('2027-03-17T16:45:00.000Z');
});
it('toIsoUtc preserves seconds and milliseconds when present', () => {
expect(toIsoUtc('2027-03-15T08:30:15')).toBe('2027-03-15T08:30:15.000Z');
expect(toIsoUtc('2027-03-15T08:30:15.123')).toBe('2027-03-15T08:30:15.123Z');
});
it('toIsoUtc round-trips with toDatetimeLocal in UTC', () => {
expect(toIsoUtc(toDatetimeLocal('2027-03-15T08:30:00.000Z'))).toBe('2027-03-15T08:30:00.000Z');
expect(toIsoUtc(toDatetimeLocal('2027-03-17T16:45:00.000Z'))).toBe('2027-03-17T16:45:00.000Z');
});
});
describe('pageTitleError', () => {
it('returns "required" for empty input', () => {
expect(pageTitleError('')).toBe('required');
expect(pageTitleError(null)).toBe('required');
expect(pageTitleError(undefined)).toBe('required');
});
it('returns "required" for whitespace-only input', () => {
expect(pageTitleError(' ')).toBe('required');
expect(pageTitleError('\t\n ')).toBe('required');
});
it('returns null for a valid title', () => {
expect(pageTitleError('OpenVelo')).toBeNull();
});
it('returns "maxlength" when the raw string exceeds 120 characters', () => {
expect(pageTitleError('a'.repeat(121))).toBe('maxlength');
});
it('returns null when surrounding whitespace keeps the trimmed length within bounds', () => {
expect(pageTitleError(' OpenVelo ')).toBeNull();
});
});
describe('normalizePageTitle', () => {
it('returns an empty string for empty input', () => {
expect(normalizePageTitle('')).toBe('');
});
it('returns an empty string for whitespace-only input', () => {
expect(normalizePageTitle(' ')).toBe('');
});
it('trims surrounding whitespace from a valid title', () => {
expect(normalizePageTitle(' OpenVelo ')).toBe('OpenVelo');
});
});
describe('pageTitleMessage', () => {
const requiredMsg = 'Page title is required and cannot contain only whitespace.';
const maxLengthMsg = 'Page title must be 120 characters or fewer.';
it('returns the required message for empty input', () => {
expect(pageTitleMessage('', null)).toBe(requiredMsg);
});
it('returns the required message for whitespace-only input', () => {
expect(pageTitleMessage(' ', null)).toBe(requiredMsg);
expect(pageTitleMessage('\t\n ', null)).toBe(requiredMsg);
});
it('returns null for a valid title', () => {
expect(pageTitleMessage('OpenVelo', null)).toBeNull();
});
it('returns the maxlength message when the raw string exceeds 120 characters', () => {
expect(pageTitleMessage('a'.repeat(121), null)).toBe(maxLengthMsg);
});
it('surfaces the whitespace validator error when control errors are provided', () => {
expect(pageTitleMessage(' ', { whitespace: true })).toBe(requiredMsg);
});
it('surfaces the required control error', () => {
expect(pageTitleMessage('', { required: true })).toBe(requiredMsg);
});
it('surfaces the maxlength control error', () => {
expect(pageTitleMessage('a'.repeat(121), { maxlength: { requiredLength: 120 } })).toBe(maxLengthMsg);
});
});
describe('isoDatetimeValidator', () => {
it('returns { invalidDatetime: true } for an empty value (empty string no longer accepted)', () => {
expect(isoDatetimeValidator({ value: '' })).toEqual({ invalidDatetime: true });
expect(isoDatetimeValidator({ value: null })).toEqual({ invalidDatetime: true });
expect(isoDatetimeValidator({ value: undefined })).toEqual({ invalidDatetime: true });
});
it('returns null for a valid ISO-8601 datetime', () => {
expect(isoDatetimeValidator({ value: '2026-08-15T10:30:00.000Z' })).toBeNull();
});
it('returns { invalidDatetime: true } for a non-datetime string', () => {
expect(isoDatetimeValidator({ value: 'not-a-date' })).toEqual({ invalidDatetime: true });
expect(isoDatetimeValidator({ value: 'also-not-a-date' })).toEqual({ invalidDatetime: true });
});
});
describe('datetimeMessage', () => {
it('returns the per-field error when controlErrors carries invalidDatetime', () => {
expect(datetimeMessage('Event start', 'not-a-date', { invalidDatetime: true }))
.toBe('Event start must be a valid ISO-8601 datetime.');
expect(datetimeMessage('Event end', 'garbage', { invalidDatetime: true }))
.toBe('Event end must be a valid ISO-8601 datetime.');
});
it('falls back to detecting an unparseable value when controlErrors are absent', () => {
expect(datetimeMessage('Event start', 'not-a-date', null))
.toBe('Event start must be a valid ISO-8601 datetime.');
});
it('returns the per-field error for an empty value (empty is now invalid)', () => {
expect(datetimeMessage('Event start', '', null)).toBe('Event start must be a valid ISO-8601 datetime.');
expect(datetimeMessage('Event end', '', { invalidDatetime: true })).toBe('Event end must be a valid ISO-8601 datetime.');
});
it('returns null for a valid ISO-8601 datetime', () => {
expect(datetimeMessage('Event start', '2026-08-15T10:30:00.000Z', null)).toBeNull();
});
});
describe('eventEndFieldMessage (Job 889)', () => {
const endIsoMsg = 'Event end must be a valid ISO-8601 datetime.';
const endBeforeMsg = 'Event end must be after event start.';
it('returns the ISO message when invalidDatetime is set, even if endBeforeStart is also set', () => {
expect(eventEndFieldMessage({ invalidDatetime: true }, { endBeforeStart: true })).toBe(endIsoMsg);
});
it('returns the end-before-start message when invalidDatetime is absent and endBeforeStart is set', () => {
expect(eventEndFieldMessage(null, { endBeforeStart: true })).toBe(endBeforeMsg);
expect(eventEndFieldMessage({}, { endBeforeStart: true })).toBe(endBeforeMsg);
});
it('returns null when both error sources are absent', () => {
expect(eventEndFieldMessage(null, null)).toBeNull();
expect(eventEndFieldMessage({}, {})).toBeNull();
});
});
describe('isValidDefaultChallengeAddress (Job 891)', () => {
it('accepts valid IPv4 addresses', () => {
expect(isValidDefaultChallengeAddress('10.66.77.88')).toBe(true);
expect(isValidDefaultChallengeAddress('127.0.0.1')).toBe(true);
expect(isValidDefaultChallengeAddress('0.0.0.0')).toBe(true);
expect(isValidDefaultChallengeAddress('255.255.255.255')).toBe(true);
});
it('accepts valid hostnames', () => {
expect(isValidDefaultChallengeAddress('challenge.example.com')).toBe(true);
expect(isValidDefaultChallengeAddress('ctf.example.org')).toBe(true);
});
it('rejects incomplete IPv4 forms', () => {
expect(isValidDefaultChallengeAddress('10.0.0.')).toBe(false);
expect(isValidDefaultChallengeAddress('10.0.0')).toBe(false);
expect(isValidDefaultChallengeAddress('10')).toBe(false);
});
it('rejects IPv4 addresses with out-of-range octets', () => {
expect(isValidDefaultChallengeAddress('999.1.1.1')).toBe(false);
expect(isValidDefaultChallengeAddress('256.0.0.1')).toBe(false);
});
it('rejects empty or whitespace-only strings', () => {
expect(isValidDefaultChallengeAddress('')).toBe(false);
expect(isValidDefaultChallengeAddress(' ')).toBe(false);
});
});
describe('defaultChallengeAddressError', () => {
it('returns "required" for empty or whitespace-only values', () => {
expect(defaultChallengeAddressError('')).toBe('required');
expect(defaultChallengeAddressError(' ')).toBe('required');
expect(defaultChallengeAddressError(null)).toBe('required');
});
it('returns "format" for malformed values', () => {
expect(defaultChallengeAddressError('10.0.0.')).toBe('format');
expect(defaultChallengeAddressError('10.0.0')).toBe('format');
expect(defaultChallengeAddressError('999.1.1.1')).toBe('format');
});
it('returns null for valid IPv4 or hostname values', () => {
expect(defaultChallengeAddressError('10.66.77.88')).toBeNull();
expect(defaultChallengeAddressError('challenge.example.com')).toBeNull();
});
});
describe('normalizeDefaultChallengeAddress', () => {
it('trims surrounding whitespace from valid input', () => {
expect(normalizeDefaultChallengeAddress(' 10.20.30.40 ')).toBe('10.20.30.40');
});
it('returns empty string for empty or whitespace-only input', () => {
expect(normalizeDefaultChallengeAddress('')).toBe('');
expect(normalizeDefaultChallengeAddress(' ')).toBe('');
});
});
describe('defaultChallengeAddressMessage', () => {
const requiredMsg = 'Default challenge IP is required and cannot contain only whitespace.';
const formatMsg = 'Default challenge IP must be a valid IPv4 address or hostname.';
it('returns the required message for empty or whitespace-only values', () => {
expect(defaultChallengeAddressMessage('', null)).toBe(requiredMsg);
expect(defaultChallengeAddressMessage(' ', null)).toBe(requiredMsg);
});
it('returns the format message for malformed values', () => {
expect(defaultChallengeAddressMessage('10.0.0.', null)).toBe(formatMsg);
expect(defaultChallengeAddressMessage('10.0.0', null)).toBe(formatMsg);
});
it('returns null for valid values', () => {
expect(defaultChallengeAddressMessage('10.66.77.88', null)).toBeNull();
expect(defaultChallengeAddressMessage('challenge.example.com', null)).toBeNull();
});
it('surfaces control-level errors when raw value would otherwise look valid', () => {
expect(defaultChallengeAddressMessage('not valid', { defaultChallengeAddressFormat: true })).toBe(formatMsg);
});
});