Files
pad/internal/mcp/dispatch_http_invalid_path_test.go
xarmian e4e914d399 fix(server): reject path segments the database cannot be asked about (BUG-2782) (#1207)
* fix(server): reject path segments the database cannot be asked about (BUG-2782)

Every handler that resolves a workspace, collection, item, comment or
attachment from a URL path segment passes that segment to the store
verbatim, and the store binds it into a text comparison. Postgres refuses
a text parameter that is not valid UTF-8 or that contains a NUL (SQLSTATE
22021 / 22P05); the driver surfaces that as a query error and the handler
answers 500. SQLite accepts both bytes and matches nothing, so the same
request is a clean 404 there — a dialect divergence that leaves the defect
invisible to self-hosted installs and live on Pad Cloud.

Measured before the fix, driving every route that carries a path parameter
with one segment set to "bad-%FF-x" (247 probes, one per parameter position
per method, real values elsewhere): Postgres answered 500 to 191 of them,
SQLite to 0. After: 0 and 0, all 247 answered 400.

Fixed with one root-level middleware rather than at ~112 chi.URLParam call
sites, because this is a transport-level input rule and per-call-site fixes
rely on every future route remembering. ValidatePath rejects a request whose
percent-DECODED path is not valid UTF-8 or contains a NUL, before routing.

It validates r.URL.Path rather than what chi hands the handler. chi routes
on RawPath when non-empty and Path otherwise, and Go populates RawPath only
when the client's escaping is not already canonical — Go escapes 0xff as
uppercase "%FF", so the CANONICAL form any ordinary client emits is exactly
the one that reaches the store decoded, and the lowercase "%ff" oddity is
the harmless one. Validating the decoded path answers both identically and
does not depend on chi continuing to prefer RawPath.

It cannot refuse Pad's own URLs: store.slugify emits only [a-z0-9-], ids
are UUIDs or hex, refs are a prefix plus digits. Valid non-ASCII segments
pass through untouched — the database accepts them and they may legitimately
name something. 400 rather than 404 because the request is malformed as a
URI and the answer does not depend on whether anything exists, so it is not
an existence oracle. Scope is the path; the query string is validated at its
points of use, per BUG-2774's validCursorID.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(server): the invalid-path rejection must look like every other API error (BUG-2782)

Codex round 1, verified before acting on: ValidatePath runs on the root
router, so its rejection short-circuits ABOVE the /api/v1 group's
cors.Handler and jsonContentType and inherited neither. Measured — the 400
carried a JSON body sniffed as text/plain and no CORS headers at all, while
a normal 404 on the same route carried Content-Type: application/json plus
the full CORS set. On a cross-origin deployment (PAD_CORS_ORIGINS set) the
browser refuses to let the page read a response with no Access-Control-
Allow-Origin, so a debuggable 400 arrives as an opaque network error.

Fixed without duplicating the CORS configuration: the group's cors.Handler
is hoisted into one shared instance, the group mounts it as before, and
ValidatePath serves its rejection THROUGH the same instance. Content-Type
is set explicitly, since jsonContentType is mounted below and never runs
for a rejection.

Moving ValidatePath down into the group instead was rejected: two covered
routes live outside it — the SPA catch-all and /api/v1/collab/{itemID} —
and the mutant that makes that move is caught by exactly those two subtests.

A genuine preflight (Origin + Access-Control-Request-Method) to an invalid
path is answered 200 by the shared handler, the same as for any other path:
a preflight asks whether the method and headers are permitted, not whether
the resource exists. The real request that follows still gets the 400, and
can now be read. Asserted rather than described.

The new test compares each header on the 400 against the SAME route
answered normally, for an allowed origin AND a disallowed one, so it pins
parity with the API's own errors rather than a header list copied from a
spec — and the disallowed-origin leg is what would fail if the rejection
echoed origins the shared handler refuses.

Mutation matrix, all nine verified to COMPILE first: dropping the CORS
decoration and dropping the explicit Content-Type are each detected, and
only by this new test.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test(server): pin the ordering decision the path check makes (BUG-2782)

Codex round 2, angle rotated to middleware contracts: a rejected request
never reaches TokenAuth, SessionAuth, RateLimit or CSRFProtect, because
ValidatePath sits on the root router above that group. The finding is
factually right and the ordering is deliberate, but nothing in the diff
said so and no test held it — which is the same defect shape as an
undocumented invariant: true today, unenforced tomorrow.

Verified rather than argued, because "bypasses the rate limiter" reads as
a weakening and here the direction is inverted. Before this middleware,
the same request ran SessionAuth — a store.ValidateSession round trip —
then the limiter, then a handler whose query the database refused, and
answered 500. It now costs a UTF-8 scan and a short JSON write with no
database contact, so the unmetered path is strictly cheaper than every
path the limiter protects. The answer is also constant for all inputs of
this shape, independent of auth and of existence, so a flood learns
nothing. And the limiter is a plain token bucket per key — no escalating
ban, no durable block — so skipping it defeats no state that outlives the
request.

The alternative, metering it inside the /api/v1 group, trades this for a
real coverage hole: the SPA catch-all and /api/v1/collab/{itemID} are
mounted outside that group.

The test floods 80 invalid paths from one IP (burst is 60), requires all
80 to be 400 and none 429, then requires a VALID request from the same IP
to still get the resolver's 404 — proving the budget was untouched. It
then asserts its own premise: the same volume of valid requests from a
second IP must actually hit the limiter, because an inert limiter would
produce an identical reading for the first half.

Both mutants land where they should: metering the rejection fails at
request 61 (burst 60 + 1, which independently confirms the constant
cited above), and disabling the limiter fails the premise check rather
than passing quietly.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs(server): correct four claims this branch's own measurements refute (BUG-2782)

Codex round 3, angle rotated onto prose accuracy. No behaviour changes —
every finding is a sentence that was stronger than what was verified, and
in two cases stronger than data already sitting in this branch.

1. "It rejects exactly what the DATABASE rejects." Too strong, and
   inherited verbatim from validCursorID. Postgres refuses these two
   classes under a UTF8 database encoding; SQL_ASCII accepts the same
   bytes, and SQLite's sqlite3_bind_text accepts arbitrary sequences with
   NUL undefined rather than erroring. Pad neither creates nor configures
   that database — nothing issues CREATE DATABASE or sets client_encoding
   — so the encoding is the operator's. Now stated as what it is: the
   strictest reading, applied uniformly so the two backends stop
   disagreeing about the same request, measured against postgres:17-alpine
   at its defaults.

   A first draft of this correction replaced the overstatement with a NEW
   unverified claim ("the encoding Pad's migrations create"). Grepping for
   CREATE DATABASE found it only in test helpers. Fixing an unchecked
   sentence with another unchecked sentence is the same defect wearing the
   repair's clothes.

2. "Against unfixed code these are 500 on Postgres and 404 on SQLite."
   False for 56 of the 247 pre-fix probes, and my own sweep output said so
   — routes whose authorization or configuration gate answers before any
   store call (admin user lookup; attachments with no storage configured).
   Replaced with the pasted distribution: 500:191 404:34 403:12 401:4
   503:4 400:2.

3. "Passed through untouched" oversold what this middleware guarantees.
   It does not touch a valid path, but chi still hands the handler the
   ESCAPED text whenever RawPath is populated: "caf%C3%A9" arrives as
   "café", the non-canonical "caf%c3%a9" arrives literally, and "%2F"
   never becomes a separator. Pre-existing chi behaviour, unaffected by
   this change, written down because the obvious reading is stronger than
   the truth.

4. "The request is malformed as a URI." It is not — "%FF" and "%00" are
   syntactically valid percent-encoded octets. The 400 is because the
   DECODED value cannot be a resource identifier here, which is the actual
   reason and a different one.

Also reconciled the two probe counts that appear in this branch's history
(111/94 GET-only, 247/191 all methods) so a reader meeting both does not
have to guess which is wrong; they are one sweep at two widths.

CONVE-23 sweep: finding 1 falsifies the same sentence in validCursorID
(handlers_timeline.go, BUG-2774), which is where this branch inherited it.
Corrected there too rather than left standing — the rule that comment
describes is unchanged and still right; only its claim about the database
was wrong.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test(mcp): drive the in-process transport seam the path check walks past (BUG-2782)

Codex round 4's exploration pointed at the door I had asserted rather than
driven: the remote /mcp transport does not reach the server over a socket.
HTTPHandlerDispatcher SYNTHESIZES an *http.Request and calls
Handler.ServeHTTP in-process, so "the middleware covers every route" was a
claim about a path this transport bypasses on its face.

Driven, it is covered — and for a chain nothing in the tree stated: Handler
is the *server.Server, chi's Mux.ServeHTTP runs mx.handler (middlewares +
routeHTTP) on BOTH branches, and buildAuthedRequest forces the fresh-routing
branch with a typed-nil RouteCtxKey. Every link is load-bearing and none was
written down; this test is what notices if one changes.

The counterfactual was worth more than the confirmation. Unfixed, an MCP
agent that put an invalid byte in a ref got upstream_error on Postgres —
whose hint says the failure is "usually transient, retry" — for an input
that can never succeed. An agent obeying that hint retries forever. That is
the retry-hostile misclassification family BUG-2675 added a code for, and
this change removes an instance of it that nobody had noticed. Now
validation_failed: the agent is told its INPUT is wrong.

The first version of this test named upstream_error in its comment while
running on SQLite, where unfixed gives item_not_found instead — an
assertion that would have failed for a reason other than the one it named.
The comment now states both backends separately and the fixture takes
Postgres when PAD_TEST_POSTGRES_URL is set, so under make test-pg the
dangerous half is what actually runs.

Control leg included: a valid-but-absent ref must still return
item_not_found, or a dispatcher that refused every ref would pass.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* chore(mcp): remove a throwaway probe that was committed by accident (BUG-2782)

The probe that established the MCP seam behaviour was meant to be deleted
once dispatch_http_invalid_path_test.go replaced it. The 'rm' was written
as the first half of a compound command whose second half the tool layer
REJECTED, so the whole command never executed — and a later 'git add -A'
swept the file in. It duplicates the real test with printf-style output
and no assertions.

The rule this breaks is one I already hold: verify the mutation, not the
report of it. I read 'rm -f X && cat > Y' as having removed X because I
wrote it, when the command never ran at all. A rejected command and a
successful one look identical in a transcript if you do not look.

Caught by a Codex file listing showing an A for a file I believed gone.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test: five corrections from the final review pass (BUG-2782)

Codex round 5, judging the whole change. All five are mine; none needed a
behaviour change.

**A flake I built in.** The rate-limit test flooded 80 requests against a
bucket of burst 60 — but a token bucket REFILLS while the loop runs, at
10/s here, so 20 tokens of headroom is 2 seconds of tolerance and a slow or
-race'd run would admit all 80 and fail spuriously. The margin that matters
is not flood-vs-burst but how long the loop must take for refill to cover
the excess. At 400 requests that is (400-60)/10 = 34 seconds against
in-process calls measured in microseconds: four orders of magnitude. The
constant now carries that derivation, including the rate and burst it
depends on. Both mutants still land, and metering the rejection still fails
at request 61 — burst 60 + 1, unchanged by the larger flood.

**A claim about MCP that JSON does not support.** The seam test's comment
said an agent could put "a raw invalid byte" into a ref. Measured with
encoding/json instead of assumed:

    raw 0xff / lone surrogate / truncated sequence → U+FFFD, valid UTF-8
    raw 0x00                                       → JSON parse error
    the u0000 ESCAPE                               → a real NUL

So exactly one of the five cases is reachable end to end over a JSON
transport, and it is the one only the NUL half of validPathText refuses.
The raw-byte cases stay — Dispatch is a Go API and the JSON decode is
upstream of that boundary, so they assert the seam holds for callers that
do not launder their strings through encoding/json — but the comment no
longer offers them as evidence a JSON client can send them.

**Two prose overstatements the earlier sweep missed.** The control test
still said the rule rejects "only what the database rejects", which the
previous commit had already established is false in the permissive
direction. And TestValidatePathPostgresNoInternalError was described as
reproducing the original 500 when it runs the FIXED server and can only
ever observe a 400; the 500 lives in the counterfactual sweep and in the
mutation matrix, and a test cannot both apply a fix and witness the bug.

**One dead construction**, plus a smaller instance of the same habit: the
MCP fixture built a SQLite store and discarded it in Postgres mode. My
first attempt replaced the comment with one claiming the branch had been
hoisted, and left the code as it was — writing the fix into the prose
instead of the code, in the same hour I committed a message about not
doing exactly that. Now actually branched.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs+test: seven more corrections, and one the emoji route earns (BUG-2782)

Codex round 6, reading the three files as they now stand. All prose or
assertion strength; one of them changes what the tests cover.

**Pad DOES emit a non-ASCII path segment, and I said it never does.**
`DELETE /workspaces/{ws}/comments/{commentID}/reactions/{emoji}` — the web
client sends `encodeURIComponent(emoji)`. So the justification I gave for
"it cannot refuse Pad's own URLs" was false in its premise while true in
its conclusion, which is the worst combination: a reader checking the
premise finds a counterexample and has no reason to trust the rest.

It is also the best possible illustration of why the rule permits valid
non-ASCII, so the control test now drives that ACTUAL route with a real
emoji rather than relying on an emoji-shaped item slug — the claim is true
by construction instead of by careful wording. A new mutant confirms the
leg discriminates: a rule that rejects all non-ASCII (the plausible wrong
version, not the absurd one) is caught there.

**"The handler answers 500" was universal and is not.** Handlers that
collapse a resolution error into not-found already answer 404 — the
timeline handler's `err != nil || item == nil` is the example. My own
measured distribution said so; the sentence did not.

**"Self-hosted installs never see it" was wrong about the axis.** The split
is by BACKEND, not deployment: a SQLite install never sees it, any Postgres
install does — Pad Cloud and a self-hoster on Postgres alike.

**A stale cross-reference of my own making.** The previous commit corrected
TestValidatePathPostgresNoInternalError's claim to reproduce the 500, and
left the sentence POINTING at it still saying it does. Fixing a claim at
one site and leaving its pointer false is the CONVE-23 case in miniature.

**The MCP test asserted too little.** validation_failed is how the
dispatcher classifies ANY 400, so the test could have passed on a
mapper-level refusal without ValidatePath running at all. It now pins the
middleware's own message, which rides through on the hint.

**Two overstatements in the same file.** "Exactly one case is reachable"
should be one input CLASS (two cases carry a NUL). And the raw-byte cases
do not cover "the stdio path": local stdio MCP is ExecDispatcher, which
shells out to the binary and never touches this in-process door. Scope now
says HTTPHandlerDispatcher and says what it does not speak for.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs(server): two qualifications the file already owed itself (BUG-2782)

Codex round 7. Two, both narrow — the review is converging (7 findings last
round, 2 this one), and both are internal inconsistencies rather than new
ground.

"Any Postgres install does" contradicted a qualification made forty lines
lower in the same file, where validPathText spells out that SQL_ASCII
Postgres accepts these bytes. Now says a Postgres install whose database
encoding is UTF8, notes that this is initdb's default, and points at the
place the qualification lives so the two cannot drift apart again.

validCursorID's paragraph still described the 500 in the present tense,
though BUG-2774 fixed it — it is the behaviour the guard PREVENTS, not what
the endpoint does. My first attempt at this appended "past tense throughout
this paragraph" and left the following sentence in the present tense, which
is annotating a problem instead of fixing it. Rewritten so the tense
carries the meaning without a note telling the reader to read it
differently.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs(server): the SQLite half of the claim needed the same narrowing (BUG-2782)

Codex round 8, one P3 and it is the mirror image of round 6's. I qualified
"the handler answers 500" on the Postgres side and left the symmetric
sentence — "the same request is a clean 404 there" — universal on the
SQLite side, in the same paragraph. Not every request reaches a store
resolution on either backend; a gate that answers first keeps its own
status, and my own GET-only sweep recorded 102 x 404 alongside 5 x 403,
2 x 200, 1 x 401 and 1 x 503 on SQLite.

Fixing one direction of a symmetric claim and leaving the other is a shape
I have hit before and evidently do not catch by intention. The paragraph
now says the divergence is in what happens once a value REACHES the store,
which is the true and symmetric statement, with the distribution pasted.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 23:20:53 -04:00

185 lines
7.8 KiB
Go

package mcp
// BUG-2782, the seam. The remote /mcp transport does NOT reach the server
// over a socket: HTTPHandlerDispatcher SYNTHESIZES an *http.Request with
// buildAuthedRequest and calls Handler.ServeHTTP in-process. So "the path
// middleware covers every route" is a claim about a door this transport
// walks past, and it is worth driving rather than reasoning about — the
// seam between two independently-correct designs is where the third bug
// lives.
//
// It is covered, for a reason nothing else in the tree states: Handler is
// the *server.Server, chi's Mux.ServeHTTP runs mx.handler (middlewares +
// routeHTTP) on BOTH of its branches, and buildAuthedRequest deliberately
// forces the fresh-routing branch with a typed-nil RouteCtxKey. Root
// middleware therefore runs for a synthesized request exactly as for a
// socket one. That chain is load-bearing and unstated; this test is what
// notices if any link in it changes.
//
// The behaviour it pins is not just "no 500". Fixed, an unbindable ref is
// validation_failed on both backends: the agent is told its INPUT is
// wrong, which is true and actionable.
//
// WHAT A JSON CALLER CAN ACTUALLY SEND, measured with encoding/json rather
// than assumed, because the first version of this comment claimed an agent
// could put "a raw invalid byte" in a ref and that is false:
//
// {"ref":"bad-<0xff>-x"} → decodes to U+FFFD — valid UTF-8, not a vector
// {"ref":"bad-\ud800-x"} → U+FFFD — not a vector
// {"ref":"bad-<0xc3><0x28>-x"} → U+FFFD — not a vector
// {"ref":"bad-<0x00>-x"} → json: invalid character in string literal
// {"ref":"bad-\u0000-x"} → a REAL NUL. The one that gets through.
//
// So over a JSON transport exactly one input CLASS below is reachable end
// to end — the NUL, which two of the cases carry. It is also the
// interesting one, since a NUL is valid UTF-8 and only the second half of
// validPathText refuses it.
//
// The raw-byte cases are kept and are not theatre. Dispatch is a Go API and
// WithDispatchInput is how a caller reaches it, so the JSON decode is
// upstream of this boundary rather than part of it; they assert the seam
// holds for a caller that does not launder its strings through
// encoding/json first. What they must NOT be read as is evidence that a
// JSON client can deliver them.
//
// Scope, stated because the obvious generalisation is wrong: this covers
// HTTPHandlerDispatcher, the REMOTE /mcp transport. Local stdio MCP is
// ExecDispatcher, which shells out to the `pad` binary and never goes
// through WithDispatchInput or this in-process door at all — nothing here
// says anything about it.
//
// Unfixed, the two backends answered DIFFERENTLY, and only one of the two
// answers is dangerous — stated per backend because the tidier single
// sentence ("unfixed this was upstream_error") is false half the time, and
// this file runs on whichever backend the suite was started with:
//
// - Postgres: upstream_error, whose hint tells the agent the failure is
// "usually transient, retry" — for an input that can never succeed. An
// agent obeying that hint retries forever. Same misclassification
// family as the retry-hostile code BUG-2675 added.
// - SQLite: item_not_found / unknown_workspace — wrong, since the ref is
// not merely absent but unaskable, though harmless to an agent.
//
// Both are wrong and the fix replaces both, so the assertions below hold on
// either backend; the upstream_error leg is the one that only reproduces
// under PAD_TEST_POSTGRES_URL, which is why the fixture takes Postgres when
// it is available rather than pinning itself to SQLite.
import (
"context"
"os"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/server"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
func newInvalidPathSeamFixture(t *testing.T) (*HTTPHandlerDispatcher, string) {
t.Helper()
// Under `make test-pg` this exercises the dangerous half — the
// retry-hostile upstream_error the fix removes. On SQLite the same
// assertions still discriminate, against the milder wrong answer.
// Branch before constructing, so Postgres mode does not also stand up
// a SQLite database and its cleanup for nothing.
var s *store.Store
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
s = storetest.NewPostgres(t)
} else {
s = storetest.NewSQLite(t)
}
srv := server.New(s)
t.Cleanup(srv.Stop)
owner, err := s.CreateUser(models.UserCreate{
Email: "seam-owner@example.com", Name: "Seam Owner",
Password: "correct-horse-battery-staple",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Seam WS", Slug: "seam-ws", OwnerID: owner.ID})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := s.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
t.Fatalf("AddWorkspaceMember: %v", err)
}
if _, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Tasks", Slug: "tasks", Prefix: "TASK",
Schema: `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`,
}); err != nil {
t.Fatalf("CreateCollection: %v", err)
}
return &HTTPHandlerDispatcher{
Handler: srv,
UserResolver: func(context.Context) *models.User { return owner },
}, ws.Slug
}
func TestHTTPDispatchInvalidPathTextIsAValidationError(t *testing.T) {
d, ws := newInvalidPathSeamFixture(t)
cases := []struct{ name, ref, workspace string }{
{"ref carries a raw invalid byte", "bad-\xff-x", ws},
{"ref carries a NUL", "bad-\x00-x", ws},
{"ref carries a truncated UTF-8 sequence", "bad-\xc3(-x", ws},
{"workspace slug carries a raw invalid byte", "TASK-1", "bad-\xff-ws"},
{"workspace slug carries a NUL", "TASK-1", "bad-\x00-ws"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := WithDispatchInput(context.Background(), map[string]any{
"ref": tc.ref, "workspace": tc.workspace,
})
res, err := d.Dispatch(ctx, []string{"item", "show"}, nil)
if err != nil {
t.Fatalf("Dispatch errored at transport: %v", err)
}
if res == nil || !res.IsError {
t.Fatalf("expected a refusal, got %+v", res)
}
body := textOf(res)
if !strings.Contains(body, "validation_failed") {
t.Fatalf("expected validation_failed, got: %s", body)
}
// validation_failed is how the dispatcher classifies ANY 400,
// so on its own it does not show that ValidatePath is what
// refused this — a mapper-level check could produce the same
// code. Pin the middleware's own message, which rides through
// on the hint.
if !strings.Contains(body, "Request path contains invalid UTF-8") {
t.Fatalf("400 did not come from ValidatePath; got: %s", body)
}
// The specific thing that must NOT come back. Unfixed, this is
// upstream_error, whose hint says the failure is usually
// transient and worth retrying — advice that is false for an
// input that can never succeed.
if strings.Contains(body, "upstream_error") || strings.Contains(body, "500") {
t.Fatalf("invalid path text reported as an upstream/transient failure: %s", body)
}
})
}
// Control: a well-formed ref that simply does not exist must still get
// the ordinary not-found answer. Without this leg a dispatcher that
// refused EVERY ref would pass every assertion above.
ctx := WithDispatchInput(context.Background(), map[string]any{
"ref": "TASK-999", "workspace": ws,
})
res, err := d.Dispatch(ctx, []string{"item", "show"}, nil)
if err != nil {
t.Fatalf("control Dispatch errored at transport: %v", err)
}
if res == nil || !res.IsError {
t.Fatalf("control: expected a not-found refusal, got %+v", res)
}
if body := textOf(res); !strings.Contains(body, "item_not_found") {
t.Fatalf("control: expected item_not_found for a valid-but-absent ref, got: %s", body)
}
}