fix(api): make GET and DELETE /api/posts/:postId/taxonomy-tags reachable - #247
Merged
Conversation
Both route patterns omitted the `/api` prefix that their POST sibling, the
`/api/posts/:postId/tags/suggestions` route and `test/e2e/
taxonomy-tagging.test.ts` all use, while both handlers parse the id with
`pathname.split("/api/posts/")[1]`. So the endpoints were unusable in two
different ways at once:
- a request to `/api/posts/:id/taxonomy-tags` matched no route — 404;
- a request to the pattern's own `/posts/:id/taxonomy-tags` reached the
handler, where `split("/api/posts/")[1]` is `undefined`, `.split()` on it
threw a TypeError into the catch, and the answer became 500.
Reading a post's taxonomy tags and removing them have therefore never worked
over HTTP. The e2e suite does call the real paths, but it needs a live
API_URL and does not run in the PR gate.
WHY NOTHING CAUGHT IT, which is the more useful half. The two existing unit
files exercised these handlers through a lookup that could not correspond to
any real request: they found the route by testing the pattern against
`/posts/post-123/taxonomy-tags` — the prefix-less pattern — and then handed
the handler `pathname: "/api/posts/post-123/taxonomy-tags"`. The halves
cancel. The lookup succeeds because the pattern lacks `/api`; the handler's
parse succeeds because the pathname has it. `routes/posts.test.ts` even
carried the comment "Router may normalize to include /api" at each call site
— the mismatch was noticed, guessed at, and never checked. It does not
normalize.
That matters beyond a broken read: the V4(a) fix which made this GET
authenticate before its existence check (the anonymous cross-tenant oracle)
is verified by `posts-taxonomy-authz.test.ts`, which was green against a
route no request could reach — and green *because* of the same mismatch.
Both lookups and every request URL in the three test files now use the `/api`
path, so a future drift of one half fails instead of cancelling. New cover in
`routes-posts-extended.test.ts`, which had none for either method: routability
via `findRoute` (the direct guard — it tests the pattern against the path), the
postId actually arriving at the authorizer rather than as `undefined`, no 500,
GET's 401-before-any-post-lookup, and DELETE's id reaching both
`DataRouter.getPost` and `removePostTaxonomyTags`.
All four fail red against the old patterns — `findRoute` returns undefined,
which is the 404 half of the defect reproduced as a test.
No snapshot change: `check-api-snapshot.mjs` reports both snapshots up to
date, and an `/api` prefix alone does not enter the OpenAPI snapshot (the POST
sibling already has one and is absent from it). `tsc --build` in apps/api is
clean; the api suite is 12264 passed / 0 failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
GETandDELETE /api/posts/:postId/taxonomy-tagshave never worked over HTTP.Both route patterns omitted the
/apiprefix that theirPOSTsibling, the/api/posts/:postId/tags/suggestionsroute, andtest/e2e/taxonomy-tagging.test.tsall use — while both handlers parse the id withpathname.split("/api/posts/")[1]. That is broken in two directions at once:/api/posts/:id/taxonomy-tags/posts/:id/taxonomy-tags(the pattern's own path)split("/api/posts/")[1]isundefined;.split()on it throws aTypeErrorinto the catch → 500So reading a post's taxonomy tags and removing them are both dead.
POST(adding tags) works — it has the prefix.Why nothing caught it
This is the part worth reading.
Two unit files already exercised these handlers, and they passed. They found the route by testing the pattern against
/posts/post-123/taxonomy-tags— the prefix-less pattern — and then handed the handlerpathname: "/api/posts/post-123/taxonomy-tags".The two halves cancel out. The lookup succeeds because the pattern lacks
/api; the handler's parse succeeds because the pathname has it. Neither half corresponds to a real request, and together they describe a configuration the router cannot produce.routes/posts.test.tseven carried this at every call site:The mismatch was noticed, guessed at, and never checked. It does not normalize —
regexToHonoPathtranslates the pattern as written andtoHonopasses the realurl.pathname.The e2e suite does call the real paths, and would have failed. It needs a live
API_URLand doesn't run in the PR gate.And this reaches a security fix. The V4(a) change that made this GET authenticate before its existence check — closing the anonymous cross-tenant post-existence oracle — is verified by
posts-taxonomy-authz.test.ts. That file was green against a route no request could reach, and green because of the same cancelling mismatch. The ordering in the handler is correct; nothing was holding it down.The fix
Add
\/apito both patterns. Minimal, and it makes the patterns agree with their POST sibling, with both handlers' own parsing, and with the e2e contract.Then remove the cancellation: every lookup and request URL in the three test files now uses the
/apipath, so if one half drifts again the tests fail instead of agreeing with each other.New cover
routes-posts-extended.test.tshad no cases for either method, which is what let this survive. Added four:findRoutetests each pattern against the path, so this asserts reachability rather than mere existence of a route object;"post-1", notundefined, plus an explicit assertion that no 500 is produced;mockDataRouterGetPostandmockCanReadPostboth un-called) — pinning V4(a) with a test that can actually fail;DataRouter.getPostandremovePostTaxonomyTags.All four fail red against the old patterns:
findRoutereturnsundefined, which is the 404 half of the defect reproduced as a test.Verification
vitest runinapps/api: 12264 passed, 0 failed (652 files, 6 skipped).tsc --buildinapps/api(thelintscript): clean.scripts/check-api-snapshot.mjs: both snapshots up to date. An/apiprefix alone does not enter the OpenAPI snapshot — the POST sibling already has one and is absent from it.email-provider-scaleway,worker-runtime/health-shutdown); both pass with it off. Not related to this change.Found while here, not fixed
DELETEstill distinguishes 404 (no such post) from 403 (post exists, not yours) after a bareDataRouter.getPost. For any authenticated caller that discriminates post existence across tenants — the same family as the V4 residuals, narrower than the anonymous oracle since it needs a session. The consistent shape would be GET's uniformtaxonomyDenyResponse, but turning a 403 into a 404 is an API behaviour change and a judgement call about the authz surface, so it belongs in the V4 residual work rather than smuggled in here.🤖 Generated with Claude Code