From a39a4892b0048fc10f754861557cdd750ca42dd9 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Wed, 22 Jul 2026 15:47:01 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/api/admin.md | 11 ++++-- docs/architecture/key-files.md | 8 ++--- docs/guides/admin-general-settings.md | 52 +++++++++++++++++++++++++-- docs/index.md | 2 +- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/api/admin.md b/docs/api/admin.md index f4422b6..851f67c 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -2,8 +2,8 @@ type: api title: Admin Endpoints description: Admin-only endpoints for user management, required general settings, categories, and supporting uploads. -tags: [api, admin, users, general, categories] -timestamp: 2026-07-22T14:24:08Z +tags: [api, admin, users, general, categories, default-challenge-ip, ip-hostname, validation] +timestamp: 2026-07-22T15:46:18Z --- # Endpoints @@ -53,7 +53,12 @@ removes the row. `superRefine` additionally requires `eventEndUtc > eventStartUtc` and reports `eventEndUtc must be strictly after eventStartUtc` on the end field when both values parse but are out of order. -* `defaultChallengeIp` 1–255 chars +* `defaultChallengeIp` is trimmed server-side; empty/whitespace-only values + are rejected (`defaultChallengeIp is required and cannot contain only + whitespace`), and the trimmed value must be a complete IPv4 address or a + valid hostname, otherwise `400 VALIDATION_FAILED` with a per-field + `defaultChallengeIp` issue (`defaultChallengeIp must be a valid IPv4 + address or hostname`). Maximum length is 255 characters. * `registrationsEnabled` boolean On success the handler persists every field via `SettingsService`, diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 2625452..9356b0a 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -2,8 +2,8 @@ type: architecture title: Key Files Index description: One-line responsibility for important source and contract-test files, including strict event-window validation. -tags: [architecture, key-files, event-window, validation] -timestamp: 2026-07-22T14:24:08Z +tags: [architecture, key-files, event-window, validation, default-challenge-ip] +timestamp: 2026-07-22T15:46:18Z --- # Backend @@ -49,8 +49,8 @@ timestamp: 2026-07-22T14:24:08Z | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. | | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. | -| `frontend/src/app/features/admin/general.pure.ts` | Pure General Settings helpers, including required datetime validation, field messages, UTC conversion, and end-after-start validation. | -| `tests/frontend/admin-general-pure.spec.ts` | Pure client-contract tests for required event timestamps, datetime messaging, UTC conversion, and event-window ordering. | +| `frontend/src/app/features/admin/general.pure.ts` | Pure General Settings helpers, including required datetime validation, field messages, UTC conversion, end-after-start validation, and the default-challenge-address IPv4/hostname validator/normalizer/message trio. | +| `tests/frontend/admin-general-pure.spec.ts` | Pure client-contract tests for required event timestamps, datetime messaging, UTC conversion, event-window ordering, and default-challenge-address validation, error mapping, and normalization. | | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. | | `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | diff --git a/docs/guides/admin-general-settings.md b/docs/guides/admin-general-settings.md index d617c83..4f6e3ff 100644 --- a/docs/guides/admin-general-settings.md +++ b/docs/guides/admin-general-settings.md @@ -2,8 +2,8 @@ type: guide title: Admin — General Settings description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page. -tags: [guide, admin, settings, general, tester, datetime, validation, utc, timezone] -timestamp: 2026-07-22T15:09:39Z +tags: [guide, admin, settings, general, tester, datetime, validation, utc, timezone, default-challenge-ip, ip-hostname] +timestamp: 2026-07-22T15:46:18Z --- # When this view is available @@ -44,7 +44,7 @@ The page is a single reactive form with these controls (every | Global theme | `general-themeKey` | `themeKey` | ` + + @if (showDefaultChallengeIpError()) { +
+ {{ defaultChallengeIpMessage() }} +
+ } @@ -209,6 +224,10 @@ export class AdminGeneralComponent implements OnInit { private readonly eventEndInvalid = signal(false); private readonly eventEndTouchedOrDirty = signal(false); + private readonly defaultChallengeIpValue = signal(''); + private readonly defaultChallengeIpInvalid = signal(false); + private readonly defaultChallengeIpTouchedOrDirty = signal(false); + readonly form = this.fb.nonNullable.group( { pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), @@ -217,7 +236,7 @@ export class AdminGeneralComponent implements OnInit { themeKey: this.fb.nonNullable.control('classic'), eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), - defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]), + defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required, this.defaultChallengeAddressValidator()]), registrationsEnabled: this.fb.nonNullable.control(false), }, { validators: endAfterStartValidator }, @@ -262,6 +281,17 @@ export class AdminGeneralComponent implements OnInit { return eventEndFieldMessage(fieldErrs, crossErrs); }); + readonly showDefaultChallengeIpError = computed( + () => this.defaultChallengeIpTouchedOrDirty() && this.defaultChallengeIpInvalid(), + ); + + readonly defaultChallengeIpMessage = computed(() => + defaultChallengeAddressMessage( + this.defaultChallengeIpValue(), + this.form.controls.defaultChallengeIp.errors, + ), + ); + constructor() { this.form.controls.welcomeMarkdown.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) @@ -317,6 +347,23 @@ export class AdminGeneralComponent implements OnInit { this.eventEndInvalid.set(ee.invalid); this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); }); + + const ip = this.form.controls.defaultChallengeIp; + this.defaultChallengeIpValue.set(ip.value); + this.defaultChallengeIpInvalid.set(ip.invalid); + this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty); + ip.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.defaultChallengeIpValue.set(v); + this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty); + }); + ip.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.defaultChallengeIpInvalid.set(ip.invalid); + this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty); + }); } async ngOnInit(): Promise { @@ -344,6 +391,19 @@ export class AdminGeneralComponent implements OnInit { }; } + private defaultChallengeAddressValidator(): (ctrl: { value: string | null }) => { defaultChallengeAddressFormat: true } | null { + return (ctrl) => { + const value = ctrl?.value ?? ''; + if (value.length === 0) return null; + if (value.trim().length === 0) return null; + if (value.length > 255) return { defaultChallengeAddressFormat: true }; + if (!isValidDefaultChallengeAddress(value)) { + return { defaultChallengeAddressFormat: true }; + } + return null; + }; + } + private applySettings(s: GeneralSettings): void { this.form.patchValue({ pageTitle: s.pageTitle, @@ -366,6 +426,10 @@ export class AdminGeneralComponent implements OnInit { this.form.controls.eventEndUtc.markAsUntouched(); this.form.controls.eventEndUtc.markAsPristine(); this.eventEndTouchedOrDirty.set(false); + + this.form.controls.defaultChallengeIp.markAsUntouched(); + this.form.controls.defaultChallengeIp.markAsPristine(); + this.defaultChallengeIpTouchedOrDirty.set(false); } async onSubmit(): Promise { @@ -385,7 +449,7 @@ export class AdminGeneralComponent implements OnInit { themeKey: v.themeKey, eventStartUtc: toIsoUtc(v.eventStartUtc), eventEndUtc: toIsoUtc(v.eventEndUtc), - defaultChallengeIp: v.defaultChallengeIp, + defaultChallengeIp: normalizeDefaultChallengeAddress(v.defaultChallengeIp), registrationsEnabled: v.registrationsEnabled, }); this.applySettings(updated); diff --git a/frontend/src/app/features/admin/general.pure.ts b/frontend/src/app/features/admin/general.pure.ts index 5a3eae2..3ef65dc 100644 --- a/frontend/src/app/features/admin/general.pure.ts +++ b/frontend/src/app/features/admin/general.pure.ts @@ -1,3 +1,68 @@ +export type DefaultChallengeAddressError = 'required' | 'format' | null; + +const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(? 3) return false; + if (part.length > 1 && part.startsWith('0')) return false; + if (!/^[0-9]+$/.test(part)) return false; + const n = Number(part); + if (!Number.isInteger(n) || n < 0 || n > 255) return false; + } + return true; +} + +function isValidHostname(value: string): boolean { + if (value.length === 0 || value.length > 253) return false; + if (value.startsWith('.') || value.endsWith('.')) return false; + const labels = value.split('.'); + if (labels.length < 2) return false; + if (labels.some((label) => ALL_NUMERIC_LABEL.test(label))) return false; + return labels.every((label) => HOSTNAME_LABEL.test(label)); +} + +export function isValidDefaultChallengeAddress(value: string): boolean { + if (isValidIpv4(value)) return true; + return isValidHostname(value); +} + +export function defaultChallengeAddressError( + value: string | null | undefined, + maxLength = 255, +): DefaultChallengeAddressError { + const raw = value ?? ''; + if (raw.trim().length === 0) return 'required'; + if (raw.length > maxLength) return 'format'; + if (!isValidDefaultChallengeAddress(raw)) return 'format'; + return null; +} + +export function normalizeDefaultChallengeAddress(value: string): string { + return value.trim(); +} + +export const defaultChallengeAddressRequiredMessage = + 'Default challenge IP is required and cannot contain only whitespace.'; + +export function defaultChallengeAddressMessage( + value: string | null | undefined, + controlErrors: { [key: string]: unknown } | null | undefined, + maxLength = 255, +): string | null { + const err = defaultChallengeAddressError(value, maxLength); + if (err === 'required') return defaultChallengeAddressRequiredMessage; + if (err === 'format') return 'Default challenge IP must be a valid IPv4 address or hostname.'; + if (controlErrors?.['required']) return defaultChallengeAddressRequiredMessage; + if (controlErrors?.['defaultChallengeAddressFormat']) { + return 'Default challenge IP must be a valid IPv4 address or hostname.'; + } + return null; +} + export type PageTitleError = 'required' | 'maxlength' | null; export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError { @@ -108,4 +173,4 @@ export function eventEndFieldMessage( return endBeforeStartMessage; } return null; -} +} \ No newline at end of file diff --git a/tests/backend/admin-general-service.spec.ts b/tests/backend/admin-general-service.spec.ts index f7c3416..bfefc8f 100644 --- a/tests/backend/admin-general-service.spec.ts +++ b/tests/backend/admin-general-service.spec.ts @@ -161,6 +161,65 @@ describe('GeneralSettingsSchema - validation rules', () => { }); }); +describe('GeneralSettingsSchema - defaultChallengeIp (Job 891)', () => { + const base = { + pageTitle: 'T', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: '2026-01-01T00:00:00Z', + eventEndUtc: '2026-02-01T00:00:00Z', + registrationsEnabled: false, + }; + + it('accepts a valid IPv4 address', () => { + const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.66.77.88' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.defaultChallengeIp).toBe('10.66.77.88'); + }); + + it('accepts a valid hostname', () => { + const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: 'challenge.example.com' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.defaultChallengeIp).toBe('challenge.example.com'); + }); + + it('trims surrounding whitespace from a valid value', () => { + const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: ' 10.20.30.40 ' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.defaultChallengeIp).toBe('10.20.30.40'); + }); + + it('rejects an empty string', () => { + const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.path.join('.') === 'defaultChallengeIp')).toBe(true); + expect(r.error.issues.some((i) => /required/i.test(i.message))).toBe(true); + }); + + it('rejects a whitespace-only value', () => { + const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: ' ' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.path.join('.') === 'defaultChallengeIp')).toBe(true); + }); + + it('rejects incomplete IPv4 forms', () => { + const r1 = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.0.0.' }); + const r2 = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.0.0' }); + expect(r1.success).toBe(false); + expect(r2.success).toBe(false); + }); + + it('rejects malformed addresses with the format message', () => { + const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '999.1.1.1' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => /IPv4/.test(i.message) || /hostname/.test(i.message))).toBe(true); + }); +}); + describe('AdminGeneralService.updateSettings - happy path', () => { it('persists all keys and emits a settings event', async () => { const stored: Record = {}; diff --git a/tests/frontend/admin-general-pure.spec.ts b/tests/frontend/admin-general-pure.spec.ts index 28f8fc1..148dda6 100644 --- a/tests/frontend/admin-general-pure.spec.ts +++ b/tests/frontend/admin-general-pure.spec.ts @@ -1,9 +1,13 @@ import { datetimeMessage, + defaultChallengeAddressError, + defaultChallengeAddressMessage, deriveEventState, endAfterStartValidator, eventEndFieldMessage, + isValidDefaultChallengeAddress, isoDatetimeValidator, + normalizeDefaultChallengeAddress, normalizePageTitle, pageTitleError, pageTitleMessage, @@ -238,3 +242,87 @@ describe('eventEndFieldMessage (Job 889)', () => { 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); + }); +}); -- 2.52.0