diff --git a/.kilo/plans/886.md b/.kilo/plans/886.md deleted file mode 100644 index 4b970a4..0000000 --- a/.kilo/plans/886.md +++ /dev/null @@ -1,36 +0,0 @@ -# Implementation Plan: Admin Area General Settings and Categories 1.04 - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** Node.js monorepo with a NestJS 10 REST backend and Angular 17 standalone frontend. Frontend components use `inject()`, Angular Signals, reactive forms, `ChangeDetectionStrategy.OnPush`, and async service methods backed by `HttpClient`/`firstValueFrom`. Global bootstrap data and CSS theme application are owned by the root-scoped `BootstrapService`; admin form persistence is owned by `AdminGeneralComponent` and `AdminService`. -- **Current flow and root cause:** Application startup calls `BootstrapService.load()`, stores the `/api/v1/bootstrap` payload, and writes its theme tokens to `document.documentElement.style`. Saving `/admin/general` successfully persists `themeKey` through `PUT /api/v1/admin/general/settings`, and the backend later resolves the chosen theme in bootstrap. However, `AdminGeneralComponent.onSubmit()` only patches the returned settings form; it never asks `BootstrapService` to refresh or apply the newly selected theme. The backend's `general` SSE event contains only `themeKey`, while the frontend event-status stream treats every hub payload as event-state data and has no theme propagation path. Thus the save response changes persistence but not the current document tokens. -- **Data Layer:** TypeORM with SQLite (`better-sqlite3`). The existing `setting` table stores `themeKey`; no schema or migration change is required. Persistent runtime data remains under `/data/hipctf` through existing setup conventions; the planned focused frontend tests require no persistent data. -- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from `tests/frontend/**/*.spec.ts`, backend tests run in Node from `tests/backend/**/*.spec.ts`, and all tests are runnable with `npm test`. The fix should follow a minimal red-green-refactor path and test logic rather than visual appearance or browser automation. -- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, or `/data` assets are required. Existing Node/npm, Angular, Jest, jsdom, TypeScript, and `setup.sh` are sufficient; `setup.sh` and lockfiles should remain unchanged. - -## 2. Impacted Files -- **To Modify:** - - `frontend/src/app/core/services/bootstrap.service.ts` — expose a typed, reusable live theme application/refresh path that updates both bootstrap client state and document CSS custom properties without being blocked by the startup request cache. - - `frontend/src/app/features/admin/general.component.ts` — inject `BootstrapService` and invoke the live theme refresh only after `updateGeneralSettings()` succeeds, before reporting Save success. -- **To Create:** - - `tests/frontend/bootstrap-theme.spec.ts` — focused jsdom tests for applying all theme token fields and refreshing to a saved non-default theme. - -## 3. Proposed Changes -1. **Database / Schema Migration:** - - No migration or backend persistence change. `AdminGeneralService.updateSettings()` already saves `SETTINGS_KEYS.THEME_KEY`, and `SystemService.bootstrap()` already reads that key and returns the corresponding complete theme object. -2. **Backend Logic & APIs:** - - Keep the existing `PUT /api/v1/admin/general/settings` and `GET /api/v1/bootstrap` contracts unchanged. The save response intentionally contains settings rather than theme tokens; reuse bootstrap as the canonical theme-token source instead of duplicating the backend theme model in the admin response. - - Do not add backend endpoints, dependencies, SSE handling, or theme-token duplication. The reported same-tab Save issue can be resolved deterministically from the successful save path, while existing persistence guarantees future reloads use the selected theme. -3. **Frontend UI Integration:** - - Replace the frontend `theme: any` shape with local typed `Theme`/`ThemeTokens` interfaces matching the bootstrap contract, including colors, font family, radii, and spacing scale. Type the theme application method accordingly and preserve the defensive no-token guard for malformed/absent payloads. - - Make theme application a reusable service operation rather than startup-only private behavior. It must continue setting all existing CSS variables (`--color-primary`, `--color-secondary`, `--color-accent`, `--color-surface`, `--color-text`, `--color-success`, `--color-warning`, `--color-danger`, `--font-family`, and all three radius properties), including valid zero-valued radius strings such as Monochrome's `0`. - - Add a force-refresh method in `BootstrapService` that performs a fresh `/api/v1/bootstrap` request instead of reusing `loadPromise`, validates HTTP success before consuming the payload, updates `payload` and `initialized`, and applies the returned theme to the live document. Keep startup `load()`/`ready()` request coalescing intact so guard/bootstrap race behavior does not regress. - - Inject `BootstrapService` into `AdminGeneralComponent`. After `AdminService.updateGeneralSettings()` resolves and the form is patched, await the force-refresh method before setting `saveOk`. This orders persistence before re-fetch, applies the canonical saved theme immediately, and also synchronizes other global bootstrap-backed settings in the current tab. - - Treat a failed refresh as a Save error rather than showing a false success: retain the persisted form response, surface the existing inline error path, and leave `submitting` cleanup in `finally`. Do not mutate theme tokens before the PUT succeeds, so failed saves leave the current live theme unchanged. - - No template or categories changes are needed; the Global Theme select already includes the server-provided canonical choices, and category behavior is unrelated to this regression. - -## 4. Test Strategy -- **Target Unit Test File:** `tests/frontend/bootstrap-theme.spec.ts`, executed by the existing root `npm test` command (or selectively through the frontend Jest project during development). Do not add visual, browser, or persistence-volume tests. -- **Mocking Strategy:** Use jsdom's real `document.documentElement.style` and mock only global `fetch`. Reset CSS properties and the fetch mock between tests to avoid shared state. Provide small typed bootstrap payload fixtures rather than Angular TestBed or a backend/database instance. -- **Core success path:** Start with Classic CSS values, have the mocked refresh return a Monochrome bootstrap payload, call the refresh method, and assert the payload signal plus representative computed CSS variables change immediately to `--color-primary: #000000`, `--color-text: #0a0a0a`, and `--radius-md: 0`; also verify all token mappings in one compact table-driven assertion so every canonical theme token category is covered. -- **Key error condition:** Mock a non-OK bootstrap refresh response and assert the method rejects without replacing the existing payload or applying partial/new CSS tokens. This supports the component's existing save-error handling while keeping the suite minimal. -- **Implementation verification:** Run the focused frontend test first to prove the regression, then `npm test`, `npm run build`, and the repository's available TypeScript builds. No lint script is defined in the root or workspace package manifests, so no additional lint dependency or setup change should be introduced. diff --git a/.kilo/plans/887.md b/.kilo/plans/887.md new file mode 100644 index 0000000..bbe4e62 --- /dev/null +++ b/.kilo/plans/887.md @@ -0,0 +1,173 @@ +# Implementation Plan: Admin General Settings — Event Start/End Validation (Job 887) + +## Status + +NOT already implemented. Two distinct gaps are described in the Job: + +1. **Backend (`PUT /api/v1/admin/general/settings`)** accepts any string for + `eventStartUtc` / `eventEndUtc` and persists it. Only the cross-field + `end > start` rule is enforced. Garbage payloads like + `eventStartUtc='not-a-date'` return HTTP 200 and overwrite the stored + schedule. +2. **Frontend (`/admin/general`)** has no per-field validation feedback for the + two `datetime-local` inputs. The cross-field rule fires correctly + (`form.invalid`, Save disabled), but the inputs render no `title`, + `aria-invalid`, `aria-describedby`, `validationMessage`, or `[data-testid=...-error]` + element when either field is empty/invalid individually. + +## 1. Architectural Reconnaissance + +- **Codebase style & conventions:** + - Backend: NestJS controllers, Zod schemas in `*.dto.ts`, validated by the + shared `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts`) + which throws `ApiError.validation('Request validation failed', details)` + mapped to HTTP 400 `VALIDATION_FAILED`. + - Frontend: Angular 17+ standalone components, signal-based state, `OnPush`, + `ReactiveFormsModule`, pure helper module at + `frontend/src/app/features/admin/general.pure.ts`. Inline error display + pattern is already used for the `pageTitle` field + (`general-pageTitle-error` + `pageTitleMessage(...)` computed signal + + `showPageTitleError()` computed) — replicate this exact pattern for the + two date inputs. +- **Data Layer:** SQLite via `better-sqlite3`. `SettingsService` stores every + general-settings key as a single string row in the `setting` table. No + schema migration is required for this job — we are tightening input + validation, not changing storage. +- **Test Framework & Structure:** + - Jest 29 with `ts-jest`, root config at `tests/jest.config.js`. Two + projects: `backend` (`tests/backend/`) and `frontend` + (`tests/frontend/`). Tests live ONLY in `/repo/tests/`. + - Single command `npm test` runs everything. +- **Required Tools & Dependencies:** None. We use the already-installed + `zod` (datetime check is built into zod ≥ 3.20) and the existing + Angular reactive-forms stack. No `setup.sh` changes are required. + +## 2. Impacted Files + +- **To Modify:** + - `backend/src/modules/admin/dto/general.dto.ts` — tighten + `eventStartUtc` / `eventEndUtc` to require real ISO-8601 datetimes. + - `frontend/src/app/features/admin/general.component.ts` — add per-field + datetime error rendering and inline error elements under both inputs. + - `frontend/src/app/features/admin/general.pure.ts` — add pure helpers + (`isoDatetimeValidator`, `datetimeMessage`) so the same logic is + unit-testable in isolation. + - `tests/backend/admin-general-service.spec.ts` — add negative cases for + non-datetime payloads. + - `tests/frontend/admin-general-pure.spec.ts` — add tests for the new + validators + message helpers. +- **To Create:** None. All changes fit inside existing files. + +## 3. Proposed Changes + +### 3.1 Backend — strict ISO-8601 datetime validation + +In `backend/src/modules/admin/dto/general.dto.ts`: + +- Replace the two loose `z.string()` declarations with zod's built-in + `.datetime({ offset: false })` check (or + `z.string().datetime({ message: '...' })` with explicit messages), so that + `GeneralSettingsSchema` rejects non-ISO-8601 strings with a clear + `INVALID_DATETIME` message before the request reaches + `SettingsService.set(...)`. This guarantees: + - The previously valid schedule is unchanged when validation fails (the + service is never called). + - The error is surfaced via the standard + `ApiError.validation('Request validation failed', details)` envelope + produced by `ZodValidationPipe` (HTTP 400 `VALIDATION_FAILED`). +- Keep the existing `superRefine` end > start rule; it now runs only on + values that already passed the per-field datetime check. +- Empty string handling: the current UI sends `''` when the user clears an + input, and the current code accepts `''`. To avoid regressing the + "unconfigured event" state, allow `z.union([z.string().datetime(...), z.literal('')])` + (an empty string is a valid "no event scheduled" value). Anything that + is neither empty nor a valid ISO datetime is rejected. + +### 3.2 Frontend — per-field datetime error rendering + +In `frontend/src/app/features/admin/general.component.ts`: + +- Apply `isoDatetimeValidator` (new, see 3.3) as a per-control validator + on both `eventStartUtc` and `eventEndUtc` (alongside the existing + group-level `endAfterStartValidator`). +- For each input, add the `aria-describedby` / `aria-invalid` attributes + and an inline `
` + (and the matching `general-eventEnd-error`) that renders when the + control is touched/dirty AND invalid, driven by a `computed` signal — + exactly mirroring the existing `showPageTitleError` / + `general-pageTitle-error` pattern in this same file. +- The message text comes from the new `datetimeMessage(value, errors)` + pure helper (see 3.3). Messages: + - non-empty value that fails `Date.parse` → + `"Event start must be a valid ISO-8601 datetime."` (or "Event end"). + - empty value → no message (empty is the intentional "unconfigured" + state; the `endBeforeStart` group validator already stays quiet when + one side is empty). +- The existing `general-endBeforeStart` element stays put; it now + coexists with the two new per-field error nodes. + +### 3.3 Frontend pure helpers + +In `frontend/src/app/features/admin/general.pure.ts`, add: + +```ts +export function isoDatetimeValidator(ctrl: { value: string | null }) { + const v = ctrl?.value ?? ''; + if (v === '') return null; // unconfigured is allowed + const t = Date.parse(v); + return Number.isFinite(t) ? null : { invalidDatetime: true }; +} + +export function datetimeMessage( + fieldLabel: string, + value: string | null | undefined, + controlErrors: { [k: string]: unknown } | null | undefined, +): string | null { + if (controlErrors?.['invalidDatetime']) { + return `${fieldLabel} must be a valid ISO-8601 datetime.`; + } + // also catch the case where the user typed something unparseable but + // the validator did not run yet (defensive) + const v = value ?? ''; + if (v !== '' && !Number.isFinite(Date.parse(v))) { + return `${fieldLabel} must be a valid ISO-8601 datetime.`; + } + return null; +} +``` + +These are exported from the existing pure file so the component can call +them directly and the spec file can exercise them in isolation. + +### 3.4 No persistence side-effects on rejection + +This is already enforced by the existing `ZodValidationPipe` (the +service is never invoked when validation fails), so no further change is +needed there. + +## 4. Test Strategy + +- **Target Unit Test Files:** + - `tests/backend/admin-general-service.spec.ts` — extend the existing + `GeneralSettingsSchema - validation rules` describe block with two + cases: + - `eventStartUtc = 'not-a-date'` → schema returns `success: false`. + - `eventStartUtc = '2026-08-15T10:30:00.000Z'` and + `eventEndUtc = 'also-not-a-date'` → schema returns + `success: false`, and the issue message includes `'eventEndUtc'` + / contains the word `datetime` (to assert the new error is the + source of the rejection, not the cross-field rule). + - `tests/frontend/admin-general-pure.spec.ts` — extend with: + - `isoDatetimeValidator` returns `null` for empty input. + - `isoDatetimeValidator` returns `null` for a valid ISO string. + - `isoDatetimeValidator` returns `{ invalidDatetime: true }` for + `'not-a-date'`. + - `datetimeMessage` returns the correct per-field message for + `'Event start'` / `'Event end'` and returns `null` for empty / + valid input. +- **Mocking Strategy:** Pure-function tests need no mocks. The existing + backend spec already constructs the schema in-process; we just feed + it bad payloads and inspect `safeParse` results. No DB, no HTTP + server, no Angular `TestBed` are required. +- **Command:** `npm test` (or the narrower + `npm run test:backend` / `npm run test:frontend`). diff --git a/backend/src/modules/admin/dto/general.dto.ts b/backend/src/modules/admin/dto/general.dto.ts index 95dc5f3..22e1bf0 100644 --- a/backend/src/modules/admin/dto/general.dto.ts +++ b/backend/src/modules/admin/dto/general.dto.ts @@ -7,8 +7,8 @@ export const GeneralSettingsSchema = z logo: z.string().max(2048), welcomeMarkdown: z.string().max(64_000), themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]), - eventStartUtc: z.string(), - eventEndUtc: z.string(), + eventStartUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' })]), + eventEndUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' })]), defaultChallengeIp: z.string().min(1).max(255), registrationsEnabled: z.boolean(), }) diff --git a/docs/api/admin.md b/docs/api/admin.md index 445e7ce..93d388c 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -46,9 +46,11 @@ removes the row. * `logo` up to 2048 chars (a public URL — uploaded separately) * `welcomeMarkdown` up to 64 000 chars * `themeKey` is one of `THEME_IDS` -* `eventStartUtc` / `eventEndUtc` parseable dates; `superRefine` - enforces `eventEndUtc > eventStartUtc` and reports the issue on - `eventEndUtc` +* `eventStartUtc` / `eventEndUtc` are validated as ISO-8601 datetimes + (zod `string().datetime(...)`) — empty strings are explicitly allowed + to keep the event window unconfigured. `superRefine` then enforces + `eventEndUtc > eventStartUtc` and reports the issue on `eventEndUtc` + when both fields are non-empty. * `defaultChallengeIp` 1–255 chars * `registrationsEnabled` boolean diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 283b5da..8f7484f 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -25,6 +25,7 @@ timestamp: 2026-07-22T13:40:00Z | `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. | | `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. | | `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. | +| `backend/src/modules/admin/dto/general.dto.ts` | Zod schema for `PUT /api/v1/admin/general/settings` — string-length rules, theme-key enum, ISO-8601 datetime check on the event-window fields (empty string allowed), and the `endAfterStart` super-refine. | # Frontend @@ -45,6 +46,8 @@ timestamp: 2026-07-22T13:40:00Z | `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. | | `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 helpers used by `AdminGeneralComponent` — `deriveEventState`, `endAfterStartValidator`, `normalizePageTitle`, `toIsoUtc`, `toDatetimeLocal`, `pageTitleError` / `pageTitleMessage`, `isoDatetimeValidator`, and `datetimeMessage`. | | `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 d2a6ccb..39cdc30 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 global platform settings (page title, logo, theme, event window, default challenge IP, registrations, welcome Markdown) from the /admin/general page. -tags: [guide, admin, settings, general, tester] -timestamp: 2026-07-22T12:44:45Z +tags: [guide, admin, settings, general, tester, datetime, validation] +timestamp: 2026-07-22T14:07:58Z --- # When this view is available @@ -42,8 +42,8 @@ The page is a single reactive form with these controls (every | Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. | | Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. | | Global theme | `general-themeKey` | `themeKey` | ` + + @if (showEventStartError()) { +
{{ eventStartMessage() }}
+ }
- + + @if (showEventEndError()) { +
{{ eventEndMessage() }}
+ } @if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) { -
Event end must be after event start.
+
Event end must be after event start.
}
@@ -179,14 +201,22 @@ export class AdminGeneralComponent implements OnInit { private readonly pageTitleInvalid = signal(false); private readonly pageTitleTouchedOrDirty = signal(false); + private readonly eventStartValue = signal(''); + private readonly eventStartInvalid = signal(false); + private readonly eventStartTouchedOrDirty = signal(false); + + private readonly eventEndValue = signal(''); + private readonly eventEndInvalid = signal(false); + private readonly eventEndTouchedOrDirty = signal(false); + readonly form = this.fb.nonNullable.group( { pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), logo: this.fb.nonNullable.control(''), welcomeMarkdown: this.fb.nonNullable.control(''), themeKey: this.fb.nonNullable.control('classic'), - eventStartUtc: this.fb.nonNullable.control(''), - eventEndUtc: this.fb.nonNullable.control(''), + eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), + eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]), registrationsEnabled: this.fb.nonNullable.control(false), }, @@ -210,6 +240,22 @@ export class AdminGeneralComponent implements OnInit { pageTitleMessage(this.pageTitleValue(), this.form.controls.pageTitle.errors), ); + readonly showEventStartError = computed( + () => this.eventStartTouchedOrDirty() && this.eventStartInvalid(), + ); + + readonly eventStartMessage = computed(() => + datetimeMessage('Event start', this.eventStartValue(), this.form.controls.eventStartUtc.errors), + ); + + readonly showEventEndError = computed( + () => this.eventEndTouchedOrDirty() && this.eventEndInvalid(), + ); + + readonly eventEndMessage = computed(() => + datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors), + ); + constructor() { this.form.controls.welcomeMarkdown.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) @@ -231,6 +277,40 @@ export class AdminGeneralComponent implements OnInit { this.pageTitleInvalid.set(pt.invalid); this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty); }); + + const es = this.form.controls.eventStartUtc; + this.eventStartValue.set(es.value); + this.eventStartInvalid.set(es.invalid); + this.eventStartTouchedOrDirty.set(es.touched || es.dirty); + es.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.eventStartValue.set(v); + this.eventStartTouchedOrDirty.set(es.touched || es.dirty); + }); + es.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.eventStartInvalid.set(es.invalid); + this.eventStartTouchedOrDirty.set(es.touched || es.dirty); + }); + + const ee = this.form.controls.eventEndUtc; + this.eventEndValue.set(ee.value); + this.eventEndInvalid.set(ee.invalid); + this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); + ee.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((v) => { + this.eventEndValue.set(v); + this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); + }); + ee.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.eventEndInvalid.set(ee.invalid); + this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); + }); } async ngOnInit(): Promise { @@ -272,6 +352,14 @@ export class AdminGeneralComponent implements OnInit { this.form.controls.pageTitle.markAsUntouched(); this.form.controls.pageTitle.markAsPristine(); this.pageTitleTouchedOrDirty.set(false); + + this.form.controls.eventStartUtc.markAsUntouched(); + this.form.controls.eventStartUtc.markAsPristine(); + this.eventStartTouchedOrDirty.set(false); + + this.form.controls.eventEndUtc.markAsUntouched(); + this.form.controls.eventEndUtc.markAsPristine(); + this.eventEndTouchedOrDirty.set(false); } async onSubmit(): Promise { diff --git a/frontend/src/app/features/admin/general.pure.ts b/frontend/src/app/features/admin/general.pure.ts index 6a44b06..0db8f5d 100644 --- a/frontend/src/app/features/admin/general.pure.ts +++ b/frontend/src/app/features/admin/general.pure.ts @@ -64,3 +64,25 @@ export function toIsoUtc(local: string): string { if (Number.isNaN(d.getTime())) return local; return d.toISOString(); } + +export function isoDatetimeValidator(ctrl: { value: string | null | undefined }): { invalidDatetime: true } | null { + const v = ctrl?.value ?? ''; + if (v === '') return null; + const t = Date.parse(v); + return Number.isFinite(t) ? null : { invalidDatetime: true }; +} + +export function datetimeMessage( + fieldLabel: string, + value: string | null | undefined, + controlErrors: { [key: string]: unknown } | null | undefined, +): string | null { + if (controlErrors?.['invalidDatetime']) { + return `${fieldLabel} must be a valid ISO-8601 datetime.`; + } + const v = value ?? ''; + if (v !== '' && !Number.isFinite(Date.parse(v))) { + return `${fieldLabel} must be a valid ISO-8601 datetime.`; + } + return null; +} diff --git a/tests/backend/admin-general-service.spec.ts b/tests/backend/admin-general-service.spec.ts index 8f37606..a54b40c 100644 --- a/tests/backend/admin-general-service.spec.ts +++ b/tests/backend/admin-general-service.spec.ts @@ -87,6 +87,73 @@ describe('GeneralSettingsSchema - validation rules', () => { expect(r.data.pageTitle).toBe('OpenVelo'); } }); + + it('rejects a non-datetime eventStartUtc ("not-a-date")', () => { + const r = GeneralSettingsSchema.safeParse({ + pageTitle: 'T', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: 'not-a-date', + eventEndUtc: '2026-02-01T00:00:00Z', + defaultChallengeIp: '127.0.0.1', + registrationsEnabled: false, + }); + expect(r.success).toBe(false); + if (!r.success) { + const paths = r.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('eventStartUtc'); + expect(r.error.issues.some((i) => /datetime/i.test(i.message))).toBe(true); + } + }); + + it('rejects a non-datetime eventEndUtc and reports the issue on eventEndUtc', () => { + const r = GeneralSettingsSchema.safeParse({ + pageTitle: 'T', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: '2026-08-15T10:30:00.000Z', + eventEndUtc: 'also-not-a-date', + defaultChallengeIp: '127.0.0.1', + registrationsEnabled: false, + }); + expect(r.success).toBe(false); + if (!r.success) { + const paths = r.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('eventEndUtc'); + expect(paths).not.toContain('eventStartUtc'); + expect(r.error.issues.some((i) => /datetime/i.test(i.message))).toBe(true); + } + }); + + it('rejects both fields as non-datetime strings (the Job negative case)', () => { + const r = GeneralSettingsSchema.safeParse({ + pageTitle: 'T', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: 'not-a-date', + eventEndUtc: 'also-not-a-date', + defaultChallengeIp: '127.0.0.1', + registrationsEnabled: false, + }); + expect(r.success).toBe(false); + }); + + it('accepts empty strings (unconfigured event window) for both datetime fields', () => { + const r = GeneralSettingsSchema.safeParse({ + pageTitle: 'T', + logo: '', + welcomeMarkdown: '', + themeKey: 'classic', + eventStartUtc: '', + eventEndUtc: '', + defaultChallengeIp: '127.0.0.1', + registrationsEnabled: false, + }); + expect(r.success).toBe(true); + }); }); describe('AdminGeneralService.updateSettings - happy path', () => { diff --git a/tests/frontend/admin-general-pure.spec.ts b/tests/frontend/admin-general-pure.spec.ts index e5c5df2..60b9c27 100644 --- a/tests/frontend/admin-general-pure.spec.ts +++ b/tests/frontend/admin-general-pure.spec.ts @@ -1,6 +1,8 @@ import { + datetimeMessage, deriveEventState, endAfterStartValidator, + isoDatetimeValidator, normalizePageTitle, pageTitleError, pageTitleMessage, @@ -161,3 +163,43 @@ describe('pageTitleMessage', () => { expect(pageTitleMessage('a'.repeat(121), { maxlength: { requiredLength: 120 } })).toBe(maxLengthMsg); }); }); + +describe('isoDatetimeValidator', () => { + it('returns null for an empty value (unconfigured is allowed)', () => { + expect(isoDatetimeValidator({ value: '' })).toBeNull(); + expect(isoDatetimeValidator({ value: null })).toBeNull(); + expect(isoDatetimeValidator({ value: undefined })).toBeNull(); + }); + + 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 null for an empty value', () => { + expect(datetimeMessage('Event start', '', null)).toBeNull(); + expect(datetimeMessage('Event start', '', { invalidDatetime: false as any })).toBeNull(); + }); + + it('returns null for a valid ISO-8601 datetime', () => { + expect(datetimeMessage('Event start', '2026-08-15T10:30:00.000Z', null)).toBeNull(); + }); +});