fix(timeline): harden curator event precision field
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m51s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m35s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 13s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m51s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m35s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 13s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
- Validate the submitted precision against the DatePrecision allow-list in parseEventForm (falls back to DAY) so an untrusted token can't flow into the request body — symmetric with the existing `type` narrowing. - Parameterize the precision input name via DatePrecisionField's new precisionInputName prop; the timeline form now submits `precision` instead of the misleading document-domain `metaDatePrecision`. Document form keeps the default, so its behaviour is unchanged. - Capture EventTypeSelect's onchange into EventForm's `type` state so it no longer goes stale (the submitted value was already correct via the hidden input; this keeps the local state in sync). Addresses PR #832 review (#781). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,9 @@ import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
|||||||
* Exposed (shared contract — both WhoWhenSection and EventForm depend on it):
|
* Exposed (shared contract — both WhoWhenSection and EventForm depend on it):
|
||||||
* - dateIso, precision, endDateIso — $bindable; the parent's binding IS the
|
* - dateIso, precision, endDateIso — $bindable; the parent's binding IS the
|
||||||
* state (no redundant $state mirror).
|
* state (no redundant $state mirror).
|
||||||
* - dateInputName / endDateInputName — names of the hidden ISO inputs.
|
* - dateInputName / endDateInputName / precisionInputName — submitted field
|
||||||
|
* names; defaults match the document form (`metaDatePrecision`), the timeline
|
||||||
|
* form overrides precisionInputName to `precision`.
|
||||||
* - initialDateIso / suggestedDateIso — seeding inputs (see onMount + $effect).
|
* - initialDateIso / suggestedDateIso — seeding inputs (see onMount + $effect).
|
||||||
* - dateTestId / precisionTestId / endDateInnerTestId — forwarded data-testid
|
* - dateTestId / precisionTestId / endDateInnerTestId — forwarded data-testid
|
||||||
* attributes so existing WhoWhenSection selectors survive the extraction.
|
* attributes so existing WhoWhenSection selectors survive the extraction.
|
||||||
@@ -28,6 +30,7 @@ let {
|
|||||||
endDateIso = $bindable(''),
|
endDateIso = $bindable(''),
|
||||||
dateInputName = 'documentDate',
|
dateInputName = 'documentDate',
|
||||||
endDateInputName = 'metaDateEnd',
|
endDateInputName = 'metaDateEnd',
|
||||||
|
precisionInputName = 'metaDatePrecision',
|
||||||
initialDateIso = '',
|
initialDateIso = '',
|
||||||
suggestedDateIso = '',
|
suggestedDateIso = '',
|
||||||
dateLabel = m.form_label_date(),
|
dateLabel = m.form_label_date(),
|
||||||
@@ -41,6 +44,7 @@ let {
|
|||||||
endDateIso?: string;
|
endDateIso?: string;
|
||||||
dateInputName?: string;
|
dateInputName?: string;
|
||||||
endDateInputName?: string;
|
endDateInputName?: string;
|
||||||
|
precisionInputName?: string;
|
||||||
initialDateIso?: string;
|
initialDateIso?: string;
|
||||||
suggestedDateIso?: string;
|
suggestedDateIso?: string;
|
||||||
dateLabel?: string;
|
dateLabel?: string;
|
||||||
@@ -145,7 +149,7 @@ $effect(() => {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="{dateInputName}Precision"
|
id="{dateInputName}Precision"
|
||||||
name="metaDatePrecision"
|
name={precisionInputName}
|
||||||
bind:value={precision}
|
bind:value={precision}
|
||||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ let selectedDocuments = $state<DocumentOption[]>(
|
|||||||
event?.documents
|
event?.documents
|
||||||
? event.documents.map((d) => ({
|
? event.documents.map((d) => ({
|
||||||
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
|
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
|
||||||
// falls back to the bare title when documentDate is the only date info present.
|
// defaults a missing precision to DAY, so the chip shows the full documentDate.
|
||||||
id: d.id,
|
id: d.id,
|
||||||
title: d.title,
|
title: d.title,
|
||||||
documentDate: d.documentDate
|
documentDate: d.documentDate
|
||||||
@@ -196,7 +196,10 @@ async function confirmDelete(e: SubmitEvent) {
|
|||||||
<span class="mb-1 block text-sm font-medium text-ink-2"
|
<span class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>{m.event_editor_type_label()}</span
|
>{m.event_editor_type_label()}</span
|
||||||
>
|
>
|
||||||
<EventTypeSelect value={type} name="type" onchange={() => markDirty()} />
|
<EventTypeSelect value={type} name="type" onchange={(t) => {
|
||||||
|
type = t;
|
||||||
|
markDirty();
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
@@ -206,6 +209,7 @@ async function confirmDelete(e: SubmitEvent) {
|
|||||||
bind:endDateIso={endDateIso}
|
bind:endDateIso={endDateIso}
|
||||||
dateInputName="eventDate"
|
dateInputName="eventDate"
|
||||||
endDateInputName="eventDateEnd"
|
endDateInputName="eventDateEnd"
|
||||||
|
precisionInputName="precision"
|
||||||
dateLabel={m.form_label_date()}
|
dateLabel={m.form_label_date()}
|
||||||
dateTestId="event-date"
|
dateTestId="event-date"
|
||||||
precisionTestId="event-precision"
|
precisionTestId="event-precision"
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
|
|||||||
// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
|
// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// Whitelist of accepted precision tokens — mirrors the DatePrecision union. Any
|
||||||
|
// other submitted value falls back to DAY rather than flowing untrusted into the
|
||||||
|
// request body (the backend enum is the hard gate; this keeps the two symmetric
|
||||||
|
// with the `type` narrowing below).
|
||||||
|
const VALID_PRECISIONS: readonly DatePrecision[] = [
|
||||||
|
'DAY',
|
||||||
|
'MONTH',
|
||||||
|
'SEASON',
|
||||||
|
'YEAR',
|
||||||
|
'RANGE',
|
||||||
|
'APPROX',
|
||||||
|
'UNKNOWN'
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the context-aware post-save / post-delete redirect target. Returns
|
* Resolves the context-aware post-save / post-delete redirect target. Returns
|
||||||
* the originating person page only when `originPersonIdRaw` is a strict UUID;
|
* the originating person page only when `originPersonIdRaw` is a strict UUID;
|
||||||
@@ -33,7 +47,9 @@ export interface ParsedEventForm {
|
|||||||
export function parseEventForm(formData: FormData): ParsedEventForm {
|
export function parseEventForm(formData: FormData): ParsedEventForm {
|
||||||
const rawType = formData.get('type')?.toString();
|
const rawType = formData.get('type')?.toString();
|
||||||
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
|
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
|
||||||
const precision = (formData.get('metaDatePrecision')?.toString() as DatePrecision) || 'DAY';
|
const rawPrecision = formData.get('precision')?.toString() as DatePrecision | undefined;
|
||||||
|
const precision: DatePrecision =
|
||||||
|
rawPrecision && VALID_PRECISIONS.includes(rawPrecision) ? rawPrecision : 'DAY';
|
||||||
const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
|
const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
|
||||||
// Off-RANGE submits an empty string → null so a stale end-date never persists.
|
// Off-RANGE submits an empty string → null so a stale end-date never persists.
|
||||||
const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
|
const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
|
|||||||
title: 'Umzug',
|
title: 'Umzug',
|
||||||
type: 'PERSONAL',
|
type: 'PERSONAL',
|
||||||
eventDate: '1925-04-01',
|
eventDate: '1925-04-01',
|
||||||
metaDatePrecision: 'YEAR',
|
precision: 'YEAR',
|
||||||
eventDateEnd: '1925-05-01'
|
eventDateEnd: '1925-05-01'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -129,6 +129,27 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
|
|||||||
expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull();
|
expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to DAY precision when an unknown precision token is submitted (REQ-009 hardening)', async () => {
|
||||||
|
const post = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await actions.save(
|
||||||
|
saveEvent({
|
||||||
|
title: 'Umzug',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
eventDate: '1925-04-01',
|
||||||
|
precision: 'NOT_A_REAL_PRECISION'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// redirect throws on success
|
||||||
|
}
|
||||||
|
expect(post.mock.calls[0][1].body.precision).toBe('DAY');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns fail(400) with preserved picker arrays on blank title', async () => {
|
it('returns fail(400) with preserved picker arrays on blank title', async () => {
|
||||||
const post = vi.fn();
|
const post = vi.fn();
|
||||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||||
|
|||||||
Reference in New Issue
Block a user