Skip to content

feat(timeentry): reject inverted teStartedAt/teEndedAt at the schema layer#130

Merged
CryptoJones merged 1 commit into
masterfrom
feat/timeentry-reject-inverted-range
May 19, 2026
Merged

feat(timeentry): reject inverted teStartedAt/teEndedAt at the schema layer#130
CryptoJones merged 1 commit into
masterfrom
feat/timeentry-reject-inverted-range

Conversation

@CryptoJones
Copy link
Copy Markdown
Owner

Closes #129.

Summary

computeMinutes in timeentrycontroller.js already maps an inverted date range (teEndedAt < teStartedAt) to teMinutes = null and keeps going — the user gets a 201 with teMinutes: null and no signal that their request was nonsense. The DB ends up with a time entry whose duration is undefined and whose bounds say "I clocked out before I clocked in." Operator error worth flagging at the boundary instead of papering over.

Adds a zod .refine() cross-field check on both createTimeEntryBody and updateTimeEntryBody. CREATE always sees both bounds when teEndedAt is supplied. PATCH only fires the refinement when both bounds are in the same request body — the single-bound PATCH case (only teEndedAt, where the existing teStartedAt lives in the DB) can't be validated at the schema layer without a DB read; that's a follow-up controller-level tightening and is intentionally still schema-accepted here.

Equality (zero-minute entries) stays allowed.

Test plan

  • npm run lint — clean
  • npm test — 490 passed (was 485 + 5 new), 15 skipped
  • Five new tests cover: inverted CREATE → 400, equality CREATE → schema-pass, in-flight CREATE (no teEndedAt) → schema-pass, inverted both-bound PATCH → 400, single-bound PATCH → schema-pass

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

…layer

`computeMinutes` in `timeentrycontroller.js` already maps an inverted
date range (`teEndedAt < teStartedAt`) to `teMinutes = null` and
keeps going — the user gets a 201 with `teMinutes: null` and no signal
that their request was nonsense. The DB ends up with a time entry
whose duration is undefined and whose bounds say "I clocked out
before I clocked in." That's operator error worth flagging at the
boundary instead of papering over.

Add a zod `.refine()` cross-field check on both `createTimeEntryBody`
and `updateTimeEntryBody`. CREATE always sees both bounds when
`teEndedAt` is supplied, so the refinement fires whenever the bug is
present. PATCH only fires when both bounds are in the same request
body — the single-bound PATCH case (only `teEndedAt`, where the
existing `teStartedAt` lives in the DB) can't be validated at the
schema layer without a DB read; that path is a follow-up
controller-level tightening and is intentionally still accepted
here so the schema stays pure.

Equality (zero-minute entry) is allowed: someone clocking a meeting
that ran exactly 0 minutes is a valid edge case, not a bug.

Five new tests cover: inverted CREATE → 400, equality CREATE →
schema-pass, in-flight CREATE (no teEndedAt) → schema-pass, inverted
both-bound PATCH → 400, single-bound PATCH → schema-pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@CryptoJones CryptoJones merged commit 42c554f into master May 19, 2026
3 checks passed
@CryptoJones CryptoJones deleted the feat/timeentry-reject-inverted-range branch May 19, 2026 06:01
CryptoJones added a commit that referenced this pull request May 19, 2026
…#146)

`createInvoiceBody` and `updateInvoiceBody` accepted any
combination of `invDate` and `invDueDate` strings. There was no
check that the due date was on or after the issue date, so an
operator could persist an invoice that was "due" before it was
issued — bookkeeping nonsense the controller had no recourse to
reject.

Same shape as the timeentry `teEndedAt >= teStartedAt` refinement
from #130: add a zod `.refine()` that fires on both the single-
and bulk-create paths, and on PATCH when both bounds appear in
the same body. Equality stays allowed — `Due on Receipt` is a
real billing term.

String comparison is safe here because `isoDate` is the strict
`^\d{4}-\d{2}-\d{2}$` regex above; lexicographic order on that
shape matches chronological order for every valid input. No need
to parse to Date objects (timeentry uses `new Date()` because
`isoDatetime` has timezone offsets in play; isoDate does not).

The bulk-create path inherits the refinement automatically because
`bulkInvoiceBody` wraps `createInvoiceBody` in `z.array(...)` and
zod runs each element's refinements during array validation —
so an attacker can't bypass the check by wrapping the bad entry
in a bulk envelope.

Single-bound PATCH (only invDueDate or only invDate) is
intentionally NOT rejected — the schema doesn't see the existing
row's other half. That's a controller-layer follow-up.

Five new tests cover: inverted single CREATE → 400, equality
CREATE → schema-pass, inverted both-bound PATCH → 400, single-
bound PATCH → schema-pass, inverted bulk entry → 400 (with the
`invoices.0.invDueDate` issue path).

Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CryptoJones added a commit that referenced this pull request May 19, 2026
…r layer (#130 follow-up) (#156)

#130 added a zod `.refine()` on createTimeEntryBody and
updateTimeEntryBody that rejects teEndedAt < teStartedAt when both
bounds are in the request body. The PR called out the
single-bound PATCH case as intentionally NOT covered there: a
PATCH that supplies only teEndedAt (or only teStartedAt) merges
against the row's existing value, which the schema layer can't
see without a DB read. Said:

> The single-bound PATCH case (only teEndedAt, validated against
> the row's existing teStartedAt) can't be enforced at the schema
> layer without a DB read. Leave that on the controller layer
> for a follow-up.

This is that follow-up. After the controller computes
`mergedStart` / `mergedEnd` from the body + existing row, check
whether the merged range is inverted; reject with 400 instead of
silently dropping `teMinutes` to null (the previous behaviour,
which the PR review confirmed had real-world bookkeeping cost:
"clocked out before clocked in" rows look correct in their two
timestamp columns but have an empty duration).

Factored the check into a small `isInvertedRange(startedAt,
endedAt)` helper, exposed via `_internals` for unit testing.
Equality (zero-minute "Due on Receipt"-style entries) stays
allowed. Unparseable input returns false from the helper because
computeMinutes' own NaN guard already null-tags those rows;
flagging them at this layer too would be double-counting a
problem we'd 400 on elsewhere.

Four new unit tests on the helper cover the four classes:
missing-bound (false), happy-path including equality (false),
inverted (true), unparseable (false).

Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

timeentry: inverted teStartedAt/teEndedAt is silently accepted with teMinutes=null

1 participant