mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
e4e914d399
* 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
185 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|