feat: Admin Area General Settings and Categories 1.09
This commit is contained in:
@@ -1,26 +0,0 @@
|
|||||||
# Implementation Plan: Admin Area General Settings UTC Datetime Round-Trip
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
- **Codebase style & conventions:** TypeScript monorepo with an Angular 17 standalone SPA and NestJS backend. The General Settings screen uses a strictly typed, non-nullable reactive form in `frontend/src/app/features/admin/general.component.ts`, while reusable formatting and validation logic is extracted to pure functions in `frontend/src/app/features/admin/general.pure.ts`. The component loads backend ISO timestamps through `toDatetimeLocal`, submits form values through `toIsoUtc`, and uses async/await around the promise-based `AdminService` API facade.
|
|
||||||
- **Data Layer:** NestJS persists general settings as string key/value rows through `SettingsService`. `AdminGeneralService.updateSettings` writes `eventStartUtc` and `eventEndUtc` verbatim after `GeneralSettingsSchema` validates timezone-aware ISO-8601 strings and strict start/end ordering. No database schema or migration is required; the defect is isolated to frontend conversion of UTC-labelled `datetime-local` values.
|
|
||||||
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from the dedicated `tests/frontend/` folder. `npm test` runs backend and frontend projects together, while `npm run test:frontend` runs the focused frontend project. Existing pure-helper coverage is in `tests/frontend/admin-general-pure.spec.ts`, which is the appropriate lightweight regression location and requires no browser UI.
|
|
||||||
- **Required Tools & Dependencies:** No new system tools, global CLI utilities, package dependencies, persistent `/data` assets, or `setup.sh` changes are required. The implementation uses built-in JavaScript `Date` UTC construction/serialization and the existing Jest toolchain.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
- **To Modify:**
|
|
||||||
- `frontend/src/app/features/admin/general.pure.ts` — make `toIsoUtc` interpret the numeric components of the UTC-labelled `datetime-local` string as UTC rather than as the browser's local timezone.
|
|
||||||
- `tests/frontend/admin-general-pure.spec.ts` — add a focused timezone regression proving the displayed UTC wall-clock components serialize unchanged and preserve existing empty/invalid behavior.
|
|
||||||
- **To Create:** None.
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
1. **Database / Schema Migration:** No changes. The existing setting keys and backend zod schema already accept and persist canonical UTC ISO strings correctly.
|
|
||||||
2. **Backend Logic & APIs:** No changes. `PUT /api/v1/admin/general/settings` already validates timezone-aware ISO-8601 input, enforces `eventEndUtc > eventStartUtc`, and stores the payload without timezone conversion; `GET /api/v1/event/status` therefore reflects whatever canonical instant the frontend sends.
|
|
||||||
3. **Frontend UI Integration:**
|
|
||||||
1. Update `toIsoUtc` in `general.pure.ts` to parse the `datetime-local` year, month, day, hour, minute, and optional second/fraction components as UTC components, then create the timestamp with `Date.UTC(...)` and return `toISOString()`.
|
|
||||||
2. Preserve the helper's current contract for empty values (`''`) and invalid values (return the original string), so the existing reactive-form validation and negative save paths remain unchanged.
|
|
||||||
3. Keep `toDatetimeLocal` unchanged because it already renders backend timestamps from UTC getters. Together, `toDatetimeLocal(iso)` and `toIsoUtc(local)` will become true timezone-independent inverses for the minute-precision native inputs used by `AdminGeneralComponent`.
|
|
||||||
4. Keep `AdminGeneralComponent.onSubmit` and the service/API contract unchanged; both event fields already pass through `toIsoUtc`, so correcting the shared helper fixes start and end submissions without UI or backend rewiring.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
- **Target Unit Test File:** `tests/frontend/admin-general-pure.spec.ts`.
|
|
||||||
- **Mocking Strategy:** No external boundaries need mocking because the defect is entirely in a pure function. Add a minimal deterministic regression around `toIsoUtc('2027-03-15T08:30') === '2027-03-15T08:30:00.000Z'` and the corresponding end value `2027-03-17T16:45`, explicitly exercising the America/Los_Angeles/PDT scenario without launching a UI. Prefer running the focused Jest process with `TZ=America/Los_Angeles` (or isolate a small child Jest invocation configured with that environment) so the test fails against the current local-time implementation and passes only when conversion is browser-timezone independent. Retain or extend the existing empty-input assertion and add only the key invalid-input assertion if needed to lock the helper's fallback contract. Verify with the root single-command suite (`npm test`) plus the existing root build/type-check path (`npm run build`); no visual confirmation or persistent test data is involved.
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Implementation Plan: Admin Area General Settings and Categories 1.09
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
- **Codebase style & conventions:** Node.js TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone SPA. The General page is an `OnPush` standalone component using a non-nullable reactive form, Angular signals/computed state, `@if` template control flow, and `takeUntilDestroyed` subscriptions. Reusable validation and message logic is kept in `frontend/src/app/features/admin/general.pure.ts`; the API validates request bodies at the controller boundary with Zod through `ZodValidationPipe`.
|
||||||
|
- **Data Layer:** TypeORM with `better-sqlite3`; general settings are key/value rows accessed through `SettingsService`. No schema migration is required because `defaultChallengeIp` already exists and only its accepted value contract changes. `AdminGeneralService.updateSettings` persists the Zod-parsed payload, so server-side normalization must happen in `GeneralSettingsSchema` before the service receives it.
|
||||||
|
- **Test Framework & Structure:** Root Jest 29 configuration with separate backend and jsdom frontend projects. Tests live under dedicated `tests/backend` and `tests/frontend` folders and are run together with `npm test` from `/repo`; targeted commands are `npm run test:backend` and `npm run test:frontend`. Existing General Settings schema tests are in `tests/backend/admin-general-service.spec.ts`, while pure Angular form-validation helpers are tested in `tests/frontend/admin-general-pure.spec.ts` without rendering a UI.
|
||||||
|
- **Required Tools & Dependencies:** No new system tools, global CLIs, package dependencies, persistent `/data` assets, or `setup.sh` changes are required. Use the existing Angular Forms APIs, Zod, Jest, TypeScript, and current npm workspace setup. Verification should run `npm test` and `npm run build`; no separate lint/typecheck scripts are defined, and both workspace builds perform the available TypeScript/Angular compilation checks.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
- **To Modify:**
|
||||||
|
- `backend/src/modules/admin/dto/general.dto.ts` — trim and strictly validate the default challenge address at the API boundary, with a field-specific validation message.
|
||||||
|
- `frontend/src/app/features/admin/general.pure.ts` — add a reusable pure default-address validator, normalization helper, and canonical inline-message mapper.
|
||||||
|
- `frontend/src/app/features/admin/general.component.ts` — attach the new validator to the reactive-form control, expose touched/dirty invalid state to the `OnPush` template, render accessible inline feedback, and submit the normalized value.
|
||||||
|
- `tests/backend/admin-general-service.spec.ts` — add focused Zod schema regression cases for accepted, normalized, and rejected default challenge addresses.
|
||||||
|
- `tests/frontend/admin-general-pure.spec.ts` — add focused tests for default-address validation, messaging, and normalization without browser/UI infrastructure.
|
||||||
|
- `docs/guides/admin-general-settings.md` — update the documented field contract, inline error selector, accessibility behavior, normalization, and test coverage.
|
||||||
|
- `docs/api/admin.md` — document server-side trimming and IP/hostname validation for `defaultChallengeIp`.
|
||||||
|
- **To Create:** None.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
1. **Database / Schema Migration:**
|
||||||
|
- Do not alter the SQLite schema or settings keys. Keep valid persisted values unchanged, including the demonstrated IPv4 value `10.66.77.88`.
|
||||||
|
- Strengthen `GeneralSettingsSchema.defaultChallengeIp` so Zod trims surrounding whitespace first, rejects an empty result, and accepts only a complete IPv4 address or a valid hostname. Implement the format predicate with built-in/runtime-safe logic (for example Node's `net.isIP(value) === 4` plus a hostname-label check) rather than adding a dependency. Reject incomplete dotted addresses such as `10.0.0.` and `10.0.0`, whitespace-only strings, IPv4 octets outside `0..255`, and malformed hostnames. Preserve the existing 255-character ceiling and return a clear field-specific message on `defaultChallengeIp`.
|
||||||
|
- Because Zod transforms before `AdminGeneralService.updateSettings`, a valid padded address such as ` 10.20.30.40 ` is persisted canonically as `10.20.30.40`, while invalid values never reach `SettingsService`. This makes the backend authoritative even if the frontend is bypassed.
|
||||||
|
|
||||||
|
2. **Backend Logic & APIs:**
|
||||||
|
- Keep `PUT /api/v1/admin/general/settings`, its controller, service, response shape, settings key, and SSE behavior unchanged. The existing `ZodValidationPipe` will emit the standard `400 VALIDATION_FAILED` envelope with an issue path of `defaultChallengeIp` for bad input.
|
||||||
|
- Keep `GET /api/v1/admin/general/settings` and public bootstrap reads unchanged; successful updates continue to round-trip the normalized value through `AdminGeneralService.getSettings` and bootstrap refresh.
|
||||||
|
- Avoid service-level duplicate validation: the parsed `GeneralSettingsPayload` is the trusted normalized contract at the service boundary.
|
||||||
|
|
||||||
|
3. **Frontend UI Integration:**
|
||||||
|
- In `general.pure.ts`, add a strict pure validator compatible with Angular's validator signature. It should trim only for checking, return a dedicated error key for empty/whitespace input and another for malformed IP/hostname input, and return `null` only for a complete IPv4 address or valid hostname. Add a normalization helper that trims the submitted value and a message helper that maps raw values/control errors to stable text, including a required message for empty/whitespace-only input and a format message for incomplete or malformed addresses.
|
||||||
|
- Replace the `defaultChallengeIp` control's `Validators.required`-only configuration with the extracted validation logic (retaining `Validators.maxLength(255)` if message handling distinguishes length). Do not use permissive browser URL parsing or `Date.parse`-style heuristics.
|
||||||
|
- Mirror the component's established Page Title/Event field signal pattern: track the default-IP raw value, validity, and touched-or-dirty state from `valueChanges` and `statusChanges` using `takeUntilDestroyed(this.destroyRef)`, then derive `showDefaultChallengeIpError` and the message with `computed` signals. Reset the control's touched/pristine state and tracking signal in `applySettings` so valid loaded or freshly saved data does not display stale errors.
|
||||||
|
- Enhance `[data-testid=general-defaultIp]` with conditional `aria-invalid="true"` and `aria-describedby="general-defaultIp-error"`. Render a `.field-error` element with both `id` and `data-testid="general-defaultIp-error"` whenever the field has been touched or dirtied and is invalid. This gives the empty case the required visible explanation and keeps Save disabled through `form.invalid` for incomplete/malformed values.
|
||||||
|
- Normalize `defaultChallengeIp` with the shared helper in `onSubmit` before calling `AdminService.updateGeneralSettings`. Valid surrounding whitespace is therefore removed rather than persisted, aligning client behavior with the authoritative backend schema. Do not mutate the control during typing, so the user can see and correct the original entry.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
- **Target Unit Test File:**
|
||||||
|
- `tests/backend/admin-general-service.spec.ts`: extend the existing `GeneralSettingsSchema - validation rules` suite with minimal cases proving a valid IPv4 survives exactly, a valid hostname is accepted, surrounding whitespace is trimmed in parsed output, and the key negatives (`''`/whitespace-only plus representative incomplete dotted forms `10.0.0.` and `10.0.0`) fail with an issue on `defaultChallengeIp` and a clear format/required message.
|
||||||
|
- `tests/frontend/admin-general-pure.spec.ts`: test the pure validator/message/normalizer directly: valid IPv4 and hostname return no error/message; empty and whitespace-only return the required error/message; incomplete dotted addresses return the format error/message; padded valid input normalizes to its trimmed value. Keep tests logic-only and independent of Angular TestBed or visual confirmation.
|
||||||
|
- **Mocking Strategy:** No external services, HTTP, database, browser automation, or persistent data are needed. Parse plain objects directly with the Zod schema and invoke pure frontend helpers with small control-shaped objects, following the existing fast Jest suites. Avoid adding a component harness or full Nest application because the behavior is fully covered at the validation boundaries and the existing form wiring uses those helpers directly. Run all suites from the root with `npm test`, then run `npm run build` to verify Angular template bindings and backend TypeScript compilation.
|
||||||
@@ -1,6 +1,53 @@
|
|||||||
|
import { isIP } from 'net';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { THEME_IDS } from '../../../common/types/theme-ids';
|
import { THEME_IDS } from '../../../common/types/theme-ids';
|
||||||
|
|
||||||
|
const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/;
|
||||||
|
const ALL_NUMERIC_LABEL = /^[0-9]+$/;
|
||||||
|
|
||||||
|
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 (isIP(value) === 4) return true;
|
||||||
|
return isValidHostname(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultChallengeIpSchema = z
|
||||||
|
.string({ required_error: 'defaultChallengeIp is required', invalid_type_error: 'defaultChallengeIp must be a string' })
|
||||||
|
.transform((v) => v.trim())
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [],
|
||||||
|
message: 'defaultChallengeIp is required and cannot contain only whitespace',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.length > 255) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [],
|
||||||
|
message: 'defaultChallengeIp must be 255 characters or fewer',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidDefaultChallengeAddress(value)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [],
|
||||||
|
message: 'defaultChallengeIp must be a valid IPv4 address or hostname',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const GeneralSettingsSchema = z
|
export const GeneralSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
pageTitle: z.string().trim().min(1).max(120),
|
pageTitle: z.string().trim().min(1).max(120),
|
||||||
@@ -9,7 +56,7 @@ export const GeneralSettingsSchema = z
|
|||||||
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
|
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
|
||||||
eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }),
|
eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }),
|
||||||
eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }),
|
eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }),
|
||||||
defaultChallengeIp: z.string().min(1).max(255),
|
defaultChallengeIp: defaultChallengeIpSchema,
|
||||||
registrationsEnabled: z.boolean(),
|
registrationsEnabled: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import { MarkdownService } from '../../core/services/markdown.service';
|
|||||||
import { AdminCategoriesComponent } from './categories/categories.component';
|
import { AdminCategoriesComponent } from './categories/categories.component';
|
||||||
import {
|
import {
|
||||||
datetimeMessage,
|
datetimeMessage,
|
||||||
|
defaultChallengeAddressMessage,
|
||||||
deriveEventState,
|
deriveEventState,
|
||||||
endAfterStartValidator,
|
endAfterStartValidator,
|
||||||
endBeforeStartMessage,
|
endBeforeStartMessage,
|
||||||
eventEndFieldMessage,
|
eventEndFieldMessage,
|
||||||
|
isValidDefaultChallengeAddress,
|
||||||
isoDatetimeValidator,
|
isoDatetimeValidator,
|
||||||
|
normalizeDefaultChallengeAddress,
|
||||||
normalizePageTitle,
|
normalizePageTitle,
|
||||||
pageTitleMessage,
|
pageTitleMessage,
|
||||||
toDatetimeLocal,
|
toDatetimeLocal,
|
||||||
@@ -126,7 +129,19 @@ import {
|
|||||||
|
|
||||||
<label for="defaultChallengeIp">Default challenge IP</label>
|
<label for="defaultChallengeIp">Default challenge IP</label>
|
||||||
<div>
|
<div>
|
||||||
<input id="defaultChallengeIp" type="text" formControlName="defaultChallengeIp" data-testid="general-defaultIp" />
|
<input
|
||||||
|
id="defaultChallengeIp"
|
||||||
|
type="text"
|
||||||
|
formControlName="defaultChallengeIp"
|
||||||
|
data-testid="general-defaultIp"
|
||||||
|
[attr.aria-invalid]="showDefaultChallengeIpError() ? 'true' : null"
|
||||||
|
[attr.aria-describedby]="showDefaultChallengeIpError() ? 'general-defaultIp-error' : null"
|
||||||
|
/>
|
||||||
|
@if (showDefaultChallengeIpError()) {
|
||||||
|
<div class="field-error" id="general-defaultIp-error" data-testid="general-defaultIp-error">
|
||||||
|
{{ defaultChallengeIpMessage() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="registrationsEnabled">Enable registrations</label>
|
<label for="registrationsEnabled">Enable registrations</label>
|
||||||
@@ -209,6 +224,10 @@ export class AdminGeneralComponent implements OnInit {
|
|||||||
private readonly eventEndInvalid = signal<boolean>(false);
|
private readonly eventEndInvalid = signal<boolean>(false);
|
||||||
private readonly eventEndTouchedOrDirty = signal<boolean>(false);
|
private readonly eventEndTouchedOrDirty = signal<boolean>(false);
|
||||||
|
|
||||||
|
private readonly defaultChallengeIpValue = signal<string>('');
|
||||||
|
private readonly defaultChallengeIpInvalid = signal<boolean>(false);
|
||||||
|
private readonly defaultChallengeIpTouchedOrDirty = signal<boolean>(false);
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group(
|
readonly form = this.fb.nonNullable.group(
|
||||||
{
|
{
|
||||||
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
|
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'),
|
themeKey: this.fb.nonNullable.control('classic'),
|
||||||
eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
|
eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
|
||||||
eventEndUtc: 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),
|
registrationsEnabled: this.fb.nonNullable.control(false),
|
||||||
},
|
},
|
||||||
{ validators: endAfterStartValidator },
|
{ validators: endAfterStartValidator },
|
||||||
@@ -262,6 +281,17 @@ export class AdminGeneralComponent implements OnInit {
|
|||||||
return eventEndFieldMessage(fieldErrs, crossErrs);
|
return eventEndFieldMessage(fieldErrs, crossErrs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly showDefaultChallengeIpError = computed(
|
||||||
|
() => this.defaultChallengeIpTouchedOrDirty() && this.defaultChallengeIpInvalid(),
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly defaultChallengeIpMessage = computed(() =>
|
||||||
|
defaultChallengeAddressMessage(
|
||||||
|
this.defaultChallengeIpValue(),
|
||||||
|
this.form.controls.defaultChallengeIp.errors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.form.controls.welcomeMarkdown.valueChanges
|
this.form.controls.welcomeMarkdown.valueChanges
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -317,6 +347,23 @@ export class AdminGeneralComponent implements OnInit {
|
|||||||
this.eventEndInvalid.set(ee.invalid);
|
this.eventEndInvalid.set(ee.invalid);
|
||||||
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
|
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<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
@@ -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 {
|
private applySettings(s: GeneralSettings): void {
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
pageTitle: s.pageTitle,
|
pageTitle: s.pageTitle,
|
||||||
@@ -366,6 +426,10 @@ export class AdminGeneralComponent implements OnInit {
|
|||||||
this.form.controls.eventEndUtc.markAsUntouched();
|
this.form.controls.eventEndUtc.markAsUntouched();
|
||||||
this.form.controls.eventEndUtc.markAsPristine();
|
this.form.controls.eventEndUtc.markAsPristine();
|
||||||
this.eventEndTouchedOrDirty.set(false);
|
this.eventEndTouchedOrDirty.set(false);
|
||||||
|
|
||||||
|
this.form.controls.defaultChallengeIp.markAsUntouched();
|
||||||
|
this.form.controls.defaultChallengeIp.markAsPristine();
|
||||||
|
this.defaultChallengeIpTouchedOrDirty.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onSubmit(): Promise<void> {
|
async onSubmit(): Promise<void> {
|
||||||
@@ -385,7 +449,7 @@ export class AdminGeneralComponent implements OnInit {
|
|||||||
themeKey: v.themeKey,
|
themeKey: v.themeKey,
|
||||||
eventStartUtc: toIsoUtc(v.eventStartUtc),
|
eventStartUtc: toIsoUtc(v.eventStartUtc),
|
||||||
eventEndUtc: toIsoUtc(v.eventEndUtc),
|
eventEndUtc: toIsoUtc(v.eventEndUtc),
|
||||||
defaultChallengeIp: v.defaultChallengeIp,
|
defaultChallengeIp: normalizeDefaultChallengeAddress(v.defaultChallengeIp),
|
||||||
registrationsEnabled: v.registrationsEnabled,
|
registrationsEnabled: v.registrationsEnabled,
|
||||||
});
|
});
|
||||||
this.applySettings(updated);
|
this.applySettings(updated);
|
||||||
|
|||||||
@@ -1,3 +1,68 @@
|
|||||||
|
export type DefaultChallengeAddressError = 'required' | 'format' | null;
|
||||||
|
|
||||||
|
const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/;
|
||||||
|
const ALL_NUMERIC_LABEL = /^[0-9]+$/;
|
||||||
|
|
||||||
|
function isValidIpv4(value: string): boolean {
|
||||||
|
const parts = value.split('.');
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.length === 0 || part.length > 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 type PageTitleError = 'required' | 'maxlength' | null;
|
||||||
|
|
||||||
export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError {
|
export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError {
|
||||||
@@ -108,4 +173,4 @@ export function eventEndFieldMessage(
|
|||||||
return endBeforeStartMessage;
|
return endBeforeStartMessage;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -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', () => {
|
describe('AdminGeneralService.updateSettings - happy path', () => {
|
||||||
it('persists all keys and emits a settings event', async () => {
|
it('persists all keys and emits a settings event', async () => {
|
||||||
const stored: Record<string, string> = {};
|
const stored: Record<string, string> = {};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
datetimeMessage,
|
datetimeMessage,
|
||||||
|
defaultChallengeAddressError,
|
||||||
|
defaultChallengeAddressMessage,
|
||||||
deriveEventState,
|
deriveEventState,
|
||||||
endAfterStartValidator,
|
endAfterStartValidator,
|
||||||
eventEndFieldMessage,
|
eventEndFieldMessage,
|
||||||
|
isValidDefaultChallengeAddress,
|
||||||
isoDatetimeValidator,
|
isoDatetimeValidator,
|
||||||
|
normalizeDefaultChallengeAddress,
|
||||||
normalizePageTitle,
|
normalizePageTitle,
|
||||||
pageTitleError,
|
pageTitleError,
|
||||||
pageTitleMessage,
|
pageTitleMessage,
|
||||||
@@ -238,3 +242,87 @@ describe('eventEndFieldMessage (Job 889)', () => {
|
|||||||
expect(eventEndFieldMessage({}, {})).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user