fix(jobnet-search): map the 1900-01-01 deadline sentinel to null in detail

search guards the API's undisclosed-deadline sentinel and a test pins
it; detail dumped the raw response, so the same field for the same job
behaved two ways, and an undisclosed deadline stored via detail read as
126 years expired - /rank's sweep would retire the job on sight. All
three output formats now flow through prepareDetail. Review finding F33
(2026-08-19).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mads Lorentzen
2026-08-19 20:50:55 +02:00
co-authored by Claude Opus 5
parent 07cec1f227
commit bcba687fbf
3 changed files with 55 additions and 4 deletions
@@ -59,6 +59,23 @@ export interface DetailApiResponse {
user: string | null user: string | null
} }
/**
* Normalize a raw detail response before any output format sees it.
*
* The API's "deadline not disclosed" sentinel is 1900-01-01 (it arrives with
* isApplicationDeadlineASAP / an applicationDeadlineStatus of NotDisclosed).
* The search command already maps that sentinel to null; detail must agree,
* or an undisclosed deadline reads as 126 years expired and /rank's expiry
* sweep retires the job the moment it is stored.
*/
export function prepareDetail(data: DetailApiResponse): DetailApiResponse {
const deadline = data.application.deadlineDate
if (deadline && deadline.startsWith("1900-01-01")) {
data.application.deadlineDate = null
}
return data
}
export const detail = defineCommand({ export const detail = defineCommand({
name: "detail", name: "detail",
description: "Full detail for a single job ad", description: "Full detail for a single job ad",
@@ -77,9 +94,10 @@ export const detail = defineCommand({
} }
try { try {
const data = await apiFetch<DetailApiResponse>( const data = prepareDetail(
`/FindJob/JobAdDetails/${id}`, await apiFetch<DetailApiResponse>(`/FindJob/JobAdDetails/${id}`, {
{ incrementViews: "false" } incrementViews: "false",
}),
) )
if (signal.aborted) return if (signal.aborted) return
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { formatDetailPlain, type DetailApiResponse } from "../src/commands/detail"; import { formatDetailPlain, prepareDetail, type DetailApiResponse } from "../src/commands/detail";
function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse { function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse {
return { return {
@@ -93,3 +93,29 @@ describe("formatDetailPlain", () => {
expect(formatted).not.toContain("Apply:"); expect(formatted).not.toContain("Apply:");
}); });
}); });
describe("prepareDetail deadline sentinel", () => {
// The API's "deadline not disclosed" sentinel is 1900-01-01 (paired with
// isApplicationDeadlineASAP / applicationDeadlineStatus). search maps it to
// null and has a test pinning that; detail dumped the raw response, so an
// undisclosed deadline read as 126 years expired and /rank's sweep would
// retire the job instantly (review finding F33, 2026-08-19).
test("maps the 1900-01-01 undisclosed sentinel to null", () => {
const data = detail();
data.application.deadlineDate = "1900-01-01T00:00:00+01:00";
expect(prepareDetail(data).application.deadlineDate).toBeNull();
});
test("keeps a real deadline unchanged", () => {
const data = detail();
data.application.deadlineDate = "2026-09-01T00:00:00+02:00";
expect(prepareDetail(data).application.deadlineDate).toBe("2026-09-01T00:00:00+02:00");
});
test("keeps a null deadline null", () => {
const data = detail();
data.application.deadlineDate = null;
expect(prepareDetail(data).application.deadlineDate).toBeNull();
});
});
+7
View File
@@ -120,6 +120,13 @@ per-file diff commands.
### Fixed ### Fixed
- **`jobnet-search detail` no longer leaks the `1900-01-01` undisclosed-deadline
sentinel** - `search` maps the API's sentinel to `null` (with a test pinning it), but
`detail` dumped the raw response, so a posting whose deadline is simply not disclosed
contributed a deadline 126 years in the past to stored data, and `/rank`'s expiry
sweep would retire the job instantly. All three output formats now flow through a
`prepareDetail` normalization that maps the sentinel to `null`. Pinned in
`tests/detail-formatting.test.ts`.
- **CI's placeholder guard now watches the CV's actual personal-data lines** - the - **CI's placeholder guard now watches the CV's actual personal-data lines** - the
sentinel for `cv/main_example.tex` was `[YOUR_NAME]`, whose only occurrences are a sentinel for `cv/main_example.tex` was `[YOUR_NAME]`, whose only occurrences are a
header comment and the hyperref `pdftitle`; `/setup`'s documented edit replaces the header comment and the hyperref `pdftitle`; `/setup`'s documented edit replaces the