test(hosts): exhaustive matrix for the workstation API (auth, allowlist, payload, freshness) #21

Closed
maximus wants to merge 1 commit from issue-14-hosts-tests into issue-13-hosts-endpoints
Owner

Fourth link of the stacked series. Base: issue-13-hosts-endpoints — review the diff against that branch, not against main.

Closes #14.

What this is

#13 shipped POST /hosts/<id> and GET /hosts with a 22-case smoke suite: enough to keep the expensive decisions from regressing silently, explicitly not the full matrix. This grows that file into the exhaustive matrix, because the ingestion route is the first publicly writable surface on the service — a mistake here is an intrusion, not an outage.

__tests__/hosts.test.js goes from 22 to 155 cases. Suite total: 61 -> 194 (14 findings + 6 health + 19 auth unchanged, 155 hosts).

Test Files  4 passed (4)
     Tests  194 passed (194)

index.js is untouched — the only file in this PR is the test file.

Coverage added

Area Cases
Ingestion token, near-miss by near-miss 9 malformed Authorization headers (wrong token, scheme alone, wrong case, no scheme, Basic, double space, one char short, one char long)
Token compartmenting ingest token refused on all 5 read routes; read token refused on the write route; HEALTH_TOKEN unset still allows ingestion; HOSTS_INGEST_TOKEN unset still allows reads
Rejection ordering 404 (routing) > 503 (ingest token unset) > 403 (outside the allowlist); a malformed id answers 404 with no token, a wrong token, the read token and the valid ingest token
Routing / traversal 11 more malformed ids added to the existing table; the 32-char boundary (403) vs 33 chars (404); GET/PUT/PATCH/DELETE on /hosts/<id>; POST /hosts; a query string that is not part of the id
Audit trail 401 and 403 each leave an [auth] line naming the route and the id; a 404 leaves none (it never reached the gate); a hostile X-Real-IP cannot break out of its line
Payload 22 field mutations (each naming the offending field), 8 bodies that are not a JSON object, an Infinity only a raw body can express, a duplicate key, and the legitimate edges (uptime: 0, empty loadAvg)
Body ceiling 4096 bytes accepted, 4097 refused, 8 KiB refused, nothing written in either refusal
Persistence 0600 mode, whitelist rebuild, 128-char cap on hostname and cpu.model, loadAvg sliced to 3, route id winning over a body id, overwrite leaving no temp debris, nested HOSTS_DIR created on demand, 500 when it cannot be
Freshness 0 / 1 / 899 / 900 / 901 / 3600 s, a future receivedAt clamped to 0, a custom HOSTS_STALE_SECONDS and its 5 fallback cases
Listing exact shape of a live entry and of a neverSeen one; sorting; a file for a non-allowlisted id never served; 8 ways a snapshot file can be corrupt, each degrading its own entry; keys and oversized strings stripped on the way out
Allowlist parsing empty falls back to the default id, all-invalid admits nobody (and closes ingestion with it), duplicates collapse

Two things worth a reviewer's attention

1. Malformed ids assert 404, never 403 — and that is load-bearing. new URL() normalises /hosts/../../etc/passwd to /etc/passwd; ..%2f..%2f, THINKPAD and a 33-character id fail HOST_ROUTE_RE. None of them reaches a handler, so 403 is reserved for ids that are well formed but absent from the allowlist. To check the tests actually bite rather than merely agreeing with today's behaviour, HOST_ROUTE_RE was temporarily widened to ^/hosts/(.+)$: 16 tests turned red. The mutation was reverted (git checkout index.js); the branch never contained it.

2. Freshness assertions freeze the clock. The 899/900/901 boundary is meaningless if the reported ageSeconds can drift by a second because the machine was busy — the 900 case (online: true, inclusive threshold) would go intermittent. So those tests pin Date.now() to a fixed instant for the duration of the request. Only Date.now is replaced, not the whole timer stack, so fetch and the http server keep their real timeouts. Second mutation check: flipping ageSeconds <= HOSTS_STALE_SECONDS to < turns exactly the two inclusive-threshold tests red, and nothing else.

One documented divergence from the issue

The acceptance criteria ask that "a 4 KiB hostname is absent from the written file". It is — but not for the reason the wording implies: MAX_BODY_BYTES is 4096, so a body carrying a 4096-character hostname is already over the ceiling and answers 413 before the sanitiser ever sees it. Both readings are therefore covered:

  • a 4096-character hostname -> 413, nothing written (the literal criterion);
  • a 3500-character hostname, which fits under the ceiling -> stored truncated to 128, and the long value is absent from the raw file text (the truncation control the criterion was aiming at).

No defect was found in the #13 implementation; every observed behaviour matches what the issue describes, this nuance aside.

Review order

#11 -> #12 -> #13 -> #14. This branch only adds tests, so it is safe to read last and on its own.


Generated autonomously by /autopilot run of 2026-08-16

Fourth link of the stacked series. **Base: `issue-13-hosts-endpoints`** — review the diff against that branch, not against `main`. Closes #14. ## What this is #13 shipped `POST /hosts/<id>` and `GET /hosts` with a 22-case smoke suite: enough to keep the expensive decisions from regressing silently, explicitly *not* the full matrix. This grows that file into the exhaustive matrix, because the ingestion route is the first publicly writable surface on the service — a mistake here is an intrusion, not an outage. `__tests__/hosts.test.js` goes from 22 to 155 cases. Suite total: **61 -> 194** (14 findings + 6 health + 19 auth unchanged, 155 hosts). ``` Test Files 4 passed (4) Tests 194 passed (194) ``` `index.js` is untouched — the only file in this PR is the test file. ## Coverage added | Area | Cases | |---|---| | Ingestion token, near-miss by near-miss | 9 malformed `Authorization` headers (wrong token, scheme alone, wrong case, no scheme, `Basic`, double space, one char short, one char long) | | Token compartmenting | ingest token refused on all 5 read routes; read token refused on the write route; `HEALTH_TOKEN` unset still allows ingestion; `HOSTS_INGEST_TOKEN` unset still allows reads | | Rejection ordering | 404 (routing) > 503 (ingest token unset) > 403 (outside the allowlist); a malformed id answers 404 with no token, a wrong token, the read token *and* the valid ingest token | | Routing / traversal | 11 more malformed ids added to the existing table; the 32-char boundary (403) vs 33 chars (404); `GET/PUT/PATCH/DELETE` on `/hosts/<id>`; `POST /hosts`; a query string that is not part of the id | | Audit trail | 401 and 403 each leave an `[auth]` line naming the route and the id; a 404 leaves none (it never reached the gate); a hostile `X-Real-IP` cannot break out of its line | | Payload | 22 field mutations (each naming the offending field), 8 bodies that are not a JSON object, an `Infinity` only a raw body can express, a duplicate key, and the legitimate edges (`uptime: 0`, empty `loadAvg`) | | Body ceiling | 4096 bytes accepted, 4097 refused, 8 KiB refused, nothing written in either refusal | | Persistence | `0600` mode, whitelist rebuild, 128-char cap on `hostname` and `cpu.model`, `loadAvg` sliced to 3, route id winning over a body id, overwrite leaving no temp debris, nested `HOSTS_DIR` created on demand, 500 when it cannot be | | Freshness | 0 / 1 / 899 / 900 / 901 / 3600 s, a future `receivedAt` clamped to 0, a custom `HOSTS_STALE_SECONDS` and its 5 fallback cases | | Listing | exact shape of a live entry and of a `neverSeen` one; sorting; a file for a non-allowlisted id never served; 8 ways a snapshot file can be corrupt, each degrading its own entry; keys and oversized strings stripped on the way out | | Allowlist parsing | empty falls back to the default id, all-invalid admits nobody (and closes ingestion with it), duplicates collapse | ## Two things worth a reviewer's attention **1. Malformed ids assert 404, never 403 — and that is load-bearing.** `new URL()` normalises `/hosts/../../etc/passwd` to `/etc/passwd`; `..%2f..%2f`, `THINKPAD` and a 33-character id fail `HOST_ROUTE_RE`. None of them reaches a handler, so 403 is reserved for ids that are well formed but absent from the allowlist. To check the tests actually bite rather than merely agreeing with today's behaviour, `HOST_ROUTE_RE` was temporarily widened to `^/hosts/(.+)$`: **16 tests turned red**. The mutation was reverted (`git checkout index.js`); the branch never contained it. **2. Freshness assertions freeze the clock.** The 899/900/901 boundary is meaningless if the reported `ageSeconds` can drift by a second because the machine was busy — the 900 case (`online: true`, inclusive threshold) would go intermittent. So those tests pin `Date.now()` to a fixed instant for the duration of the request. Only `Date.now` is replaced, not the whole timer stack, so `fetch` and the http server keep their real timeouts. Second mutation check: flipping `ageSeconds <= HOSTS_STALE_SECONDS` to `<` turns exactly the two inclusive-threshold tests red, and nothing else. ## One documented divergence from the issue The acceptance criteria ask that "a 4 KiB `hostname` is absent from the written file". It is — but not for the reason the wording implies: `MAX_BODY_BYTES` is 4096, so a body carrying a 4096-character hostname is already over the ceiling and answers **413 before the sanitiser ever sees it**. Both readings are therefore covered: - a 4096-character hostname -> 413, nothing written (the literal criterion); - a 3500-character hostname, which fits under the ceiling -> stored truncated to 128, and the long value is absent from the raw file text (the truncation control the criterion was aiming at). No defect was found in the #13 implementation; every observed behaviour matches what the issue describes, this nuance aside. ## Review order `#11 -> #12 -> #13 -> #14`. This branch only adds tests, so it is safe to read last and on its own. --- Generated autonomously by /autopilot run of 2026-08-16
maximus added 1 commit 2026-08-16 16:06:17 +00:00
The suite that shipped with the implementation pinned the decisions that
were expensive to get wrong. This turns it into the exhaustive matrix the
surface deserves: POST /hosts/<id> is the only publicly writable endpoint
on the service, so a mistake there is an intrusion rather than an outage.

__tests__/hosts.test.js goes from 22 to 155 cases (suite 61 -> 194):

- auth: nine near-miss Authorization headers, the read token refused on
  the write route, the ingest token refused on all five read routes, and
  the two tokens proven independent — either one unset leaves the other
  path working
- rejection ordering: 404 (routing) beats 503 (ingest token unset) beats
  403 (outside the allowlist), and a malformed id answers 404 whatever
  token it carries, so route existence stays unenumerable
- routing: eleven more malformed ids, every one of them a 404. Widening
  HOST_ROUTE_RE to `^/hosts/(.+)$` turns sixteen of them red, which is
  what makes the narrow pattern a traversal control rather than a comment
- payload: twenty-two field mutations, eight bodies that are not a JSON
  object, an Infinity only a raw body can express, and the 4 KiB ceiling
  pinned from both sides — 4096 accepted, 4097 and 8 KiB refused
- persistence: 0600 mode, whitelist rebuild, 128-character cap, loadAvg
  sliced to three, the route id winning over an id in the body, an
  overwrite leaving no temp debris, and the 500 path when HOSTS_DIR
  cannot be created
- freshness: 899/900/901 exact against a frozen Date.now() instead of the
  wall clock, plus a custom HOSTS_STALE_SECONDS and its fallbacks
- listing: eight ways a snapshot file can be corrupt, each degrading its
  own entry while a healthy neighbour keeps its data

The 4 KiB hostname of the acceptance criteria is covered twice, because
the body ceiling fires before the sanitiser ever sees it: 4096 characters
are refused with 413, and 3500 characters (which fit) are truncated to
128 with the long value absent from the file.

index.js is untouched. No defect surfaced, and the two mutations used to
prove the net bites were reverted.

Resolves #14
maximus added the
autopilot:pending-human
label 2026-08-16 16:06:31 +00:00
Author
Owner

Verdict : APPROVE

155 tests dans hosts.test.js au tip. La discipline sur les bornes est reelle et rare : 4096 contre 4097 octets, id de 32 contre 33 caracteres, fraicheur 899/900/901 avec horloge gelee plutot qu'une course contre le wall-clock, 1e999 -> Infinity, cles JSON dupliquees, uptime: 0 distingue d'un champ manquant, mode 0600, absence de debris temporaire, 500 sur repertoire non inscriptible, X-Real-IP hostile confine a une ligne d'ASCII imprimable, __proto__/constructor/prototype absents du texte du fichier, id de route prioritaire sur un id fourni dans le corps.

Le choix de geler Date.now() au lieu de tolerer une marge est le bon : les bornes de fraicheur sont exactes au lieu d'etre « environ N, sauf si la machine rame ».

Suggestions (non bloquantes)

  1. Les quatre tests 413 ne peuvent pas echouer pour la bonne raison. ingestOversized convertit toute erreur de transport en 413 :

    try { const { status } = await ingest(route, { body }); return status; } catch { return 413; }
    

    Si le serveur cessait d'ecrire le 413 et se contentait de detruire la socket, les quatre resteraient verts. Le compromis est documente et l'assertion sur le systeme de fichiers porte du poids reel — mais le code de statut lui-meme n'est actuellement fige nulle part. Un seul test emettant un http.request brut et lisant res.statusCode avant la destruction de la socket fermerait le trou. C'est le seul chemin ou le contrat client (un statut, pas une erreur reseau) est precisement l'enjeu — et c'est aussi la seule chose que le corps de la PR #20 promet au consommateur pour ce cas.

  2. frozenRequest remplace le Date.now global le temps d'un aller-retour reseau. Sans danger ici (tests sequentiels, et le receivedAt de writeHostSnapshot passe par new Date(), que le patch ne touche pas) — mais cette non-interference est load-bearing et non ecrite. Un futur test qui ferait un POST a l'interieur d'un frozenRequest obtiendrait silencieusement un receivedAt reel confronte a un now gele. Une ligne de commentaire suffirait.

  3. Meme trou /hosts que le point 1 de ma revue de #20 : c'etait la PR la mieux placee pour le fermer. /hosts n'a ete ajoute qu'a la liste « the ingestion token opens nothing on the read side », jamais a la matrice en-tete-manquant, et auth.test.js n'a toujours pas ete touche.


Revue adversariale — maillon 4/5, revu contre sa base issue-13-hosts-endpoints.

## Verdict : APPROVE 155 tests dans `hosts.test.js` au tip. La discipline sur les bornes est reelle et rare : 4096 contre 4097 octets, id de 32 contre 33 caracteres, fraicheur 899/900/901 avec horloge gelee plutot qu'une course contre le wall-clock, `1e999` -> Infinity, cles JSON dupliquees, `uptime: 0` distingue d'un champ manquant, mode 0600, absence de debris temporaire, 500 sur repertoire non inscriptible, `X-Real-IP` hostile confine a une ligne d'ASCII imprimable, `__proto__`/`constructor`/`prototype` absents du texte du fichier, id de route prioritaire sur un id fourni dans le corps. Le choix de geler `Date.now()` au lieu de tolerer une marge est le bon : les bornes de fraicheur sont exactes au lieu d'etre « environ N, sauf si la machine rame ». ### Suggestions (non bloquantes) 1. **Les quatre tests 413 ne peuvent pas echouer pour la bonne raison.** `ingestOversized` convertit toute erreur de transport en `413` : ```js try { const { status } = await ingest(route, { body }); return status; } catch { return 413; } ``` Si le serveur cessait d'ecrire le 413 et se contentait de detruire la socket, les quatre resteraient verts. Le compromis est documente et l'assertion sur le systeme de fichiers porte du poids reel — mais le code de statut lui-meme n'est actuellement fige nulle part. Un seul test emettant un `http.request` brut et lisant `res.statusCode` avant la destruction de la socket fermerait le trou. C'est le seul chemin ou le contrat client (un statut, pas une erreur reseau) est precisement l'enjeu — et c'est aussi la seule chose que le corps de la PR #20 promet au consommateur pour ce cas. 2. **`frozenRequest` remplace le `Date.now` global le temps d'un aller-retour reseau.** Sans danger ici (tests sequentiels, et le `receivedAt` de `writeHostSnapshot` passe par `new Date()`, que le patch ne touche pas) — mais cette non-interference est load-bearing et non ecrite. Un futur test qui ferait un POST a l'interieur d'un `frozenRequest` obtiendrait silencieusement un `receivedAt` reel confronte a un `now` gele. Une ligne de commentaire suffirait. 3. **Meme trou `/hosts` que le point 1 de ma revue de #20** : c'etait la PR la mieux placee pour le fermer. `/hosts` n'a ete ajoute qu'a la liste « the ingestion token opens nothing on the read side », jamais a la matrice en-tete-manquant, et `auth.test.js` n'a toujours pas ete touche. --- *Revue adversariale — maillon 4/5, revu contre sa base `issue-13-hosts-endpoints`.*
Author
Owner

Mergee localement sur main en fast-forward (pile chainee : l API de merge Forgejo ne peut pas traiter une pile dont la base n est pas main). Tip integre : e181a96. Forgejo ne detecte pas un merge ff local, donc cette PR est fermee a la main — le code EST sur main.

Mergee localement sur `main` en fast-forward (pile chainee : l API de merge Forgejo ne peut pas traiter une pile dont la base n est pas `main`). Tip integre : `e181a96`. Forgejo ne detecte pas un merge ff local, donc cette PR est fermee a la main — le code EST sur `main`.
maximus closed this pull request 2026-08-16 18:27:39 +00:00

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/vps-health-api#21
No description provided.