console: cover the paths door's refusals, and read back the published map
Review round 1. Three gaps, none of them in behaviour that shipped wrong: The ErrBadPathTarget 422 had no test on either side, so deleting the %w wrap in claims.go restored the 502 "try again" with a green suite. Covered at the request level and through the sentinel in the domains unit test; both fail without the wrap. The control-plane guard on the paths door had no test, against a file with one per call site. Added, including the constant-across-host-shapes property the guard's placement before normalisation exists to give. Spec section 8's other read surface was missing, and nothing exposed a claim's Paths at all: `carlos routes` lists registry-derived rows, and the reconcile pass drops any entry whose target is not a same-account local route — so a refused flip was visible nowhere. The per-host claim door now answers the map as published, and `carlos domains paths <host>` reads it. Read-only, and its footer names which kind of state it is showing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+423
−11
apimode.go+28 −0domains_cli.go+69 −1domains_cli_test.go+95 −0internal/console/apidomainpaths_test.go+92 −0internal/console/apidomains.go+28 −0internal/console/controlplane_test.go+83 −0internal/domains/paths_test.go+28 −10
diff --git a/apimode.go b/apimode.go| index 54af7d3c4..ccfbebd2e 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2684,6 +2684,34 @@ func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]a |
| return res.Domains, nil |
| } |
| +// domainPaths reads ONE hostname's published prefix map off the per-host |
| +// claim door. Published, not derived: this is the map the owning platform |
| +// PUT, which is a different fact from the route_paths rows `carlos routes` |
| +// prints. The reconcile pass drops an entry whose target is not a |
| +// same-account route on the box, so a prefix present here and absent there is |
| +// a refused flip — the one thing an operator debugging a migration needs to |
| +// see, and invisible everywhere else. |
| +// |
| +// A console that predates path routing sends no `paths` key, which decodes as |
| +// an empty map: "this hostname splits no traffic" is the same answer such a |
| +// deployment would give if asked, so there is nothing to distinguish. |
| +func (s *apiSession) domainPaths(ctx context.Context, a apiAcct, app, host string) (map[string]string, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host))) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Paths map[string]string `json:"paths"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("domains paths: unreadable response: %w", err) |
| + } |
| + return res.Paths, nil |
| +} |
| + |
| // shipTimeout bounds ship's own context — generous on purpose. The console |
| // gives itself up to 10 minutes to read the request body and 2 minutes to |
| // store it (internal/console/apicli.go), well past the 30s absolute Timeout |
diff --git a/domains_cli.go b/domains_cli.go| index 8f602970c..04b130ea1 100644 |
| --- a/domains_cli.go |
| +++ b/domains_cli.go |
| @@ -12,11 +12,12 @@ import ( |
| "flag" |
| "fmt" |
| "log" |
| + "sort" |
| "strings" |
| "time" |
| ) |
| -const domainsUsage = `usage: carlos domains <attach|detach|list> [flags] |
| +const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags] |
| attach --app <a> [--route <r>] [--wildcard] [--catchall] [flags] <hostname> |
| claim a customer's hostname for the app, pointed at |
| @@ -29,6 +30,11 @@ const domainsUsage = `usage: carlos domains <attach|detach|list> [flags] |
| list --app <a> [flags] |
| this app's hostname claims, joined to the fleet's own |
| readings sweep (DNS/CERT-EXPIRES/WILDCARD/CATCHALL/DELEGATION) |
| + paths --app <a> [flags] <hostname> |
| + the leading path segments this hostname's traffic is split |
| + by, as PUBLISHED. Read-only: the platform that owns the |
| + hostname republishes the whole map, so there is no verb |
| + here that writes one. |
| --account <a> account (sqid or name; default "ops") |
| --account-id <id> account sqid, matched as a sqid ONLY |
| @@ -49,6 +55,9 @@ const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a |
| const domainsListUsage = `usage: carlos domains list --app <a> [--account <a>] [--account-id <id>] [--console <origin>] |
| ` |
| +const domainsPathsUsage = `usage: carlos domains paths --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname> |
| +` |
| + |
| // runDomains dispatches the domains sub-verbs. attach/detach/list are |
| // positional words, not flags, and must never reach fs.Parse — the same |
| // shape runInstances uses for its own sub-verbs. |
| @@ -64,6 +73,9 @@ func runDomains(args []string) { |
| case "list": |
| runDomainsList(args[1:]) |
| return |
| + case "paths": |
| + runDomainsPaths(args[1:]) |
| + return |
| } |
| } |
| log.Fatal(domainsUsage) |
| @@ -260,6 +272,62 @@ func runDomainsList(args []string) { |
| w.Flush() |
| } |
| +// runDomainsPaths prints one hostname's PUBLISHED prefix map — the leading |
| +// path segments its traffic is split by, and the route each is served by. |
| +// |
| +// This is desired state, and the distinction from `carlos routes` is the whole |
| +// reason the verb exists. `carlos routes` prints the route_paths rows a box |
| +// derived, and the reconcile pass silently drops any entry whose target is not |
| +// a route of the same account on that box. So a prefix that appears HERE and |
| +// not there is a flip the platform published and the deployment refused — |
| +// which is the state an operator debugs, and which no other surface shows. The |
| +// footer says so rather than leaving the reader to know it. |
| +// |
| +// READ-ONLY, and there is deliberately no sibling that writes: the platform |
| +// that owns the hostname regenerates this map from its own database and |
| +// republishes it wholesale, so a CLI writer would be a second writer of state |
| +// whose source of truth is elsewhere, and the two would drift the first time |
| +// anyone used it. |
| +func runDomainsPaths(args []string) { |
| + fs := flag.NewFlagSet("domains paths", flag.ExitOnError) |
| + app := appFlag(fs, "app the hostname belongs to") |
| + consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, domainsPathsUsage) |
| + fs.Parse(args) |
| + host := strings.TrimSpace(fs.Arg(0)) |
| + if host == "" || fs.NArg() > 1 { |
| + log.Fatal(domainsPathsUsage) |
| + } |
| + sess := requireAPISession("paths", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, domainsPathsUsage) |
| + paths, err := sess.domainPaths(context.Background(), acct, app2, host) |
| + if err != nil { |
| + log.Fatalf("domains paths: %v", err) |
| + } |
| + if len(paths) == 0 { |
| + mutedf(stdout, "%s splits no traffic by path", host) |
| + return |
| + } |
| + // Sorted by prefix: a map has no order, and a listing that reshuffles |
| + // between calls is one nobody can diff. |
| + prefixes := make([]string, 0, len(paths)) |
| + for p := range paths { |
| + prefixes = append(prefixes, p) |
| + } |
| + sort.Strings(prefixes) |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "PREFIX\tTARGET") |
| + for _, p := range prefixes { |
| + fmt.Fprintf(w, "/%s\t%s\n", p, paths[p]) |
| + } |
| + w.Flush() |
| + mutedf(stdout, "(published desired state; \"carlos routes --app %s\" shows which of these a box is actually serving)", app2) |
| +} |
| + |
| // printDomainRecords prints the records the member must create, grouped by |
| // alternative. Nothing is printed when the door sent none — a pointing |
| // hostname has nothing left to do, and an older console sends no records at |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index abed78e91..92872fe70 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -271,6 +271,12 @@ type fakeDomainsConsole struct { |
| attachCreated bool |
| lastDetachPath string |
| detachRemoved bool |
| + // paths is what the per-host GET answers as the hostname's PUBLISHED |
| + // prefix map, and lastPathsPath records the URL it was asked for. nil |
| + // means the console sends no `paths` key at all — the shape a deployment |
| + // predating path routing sends. |
| + paths map[string]string |
| + lastPathsPath string |
| } |
| func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| @@ -299,6 +305,18 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| case r.Method == http.MethodDelete: |
| fc.lastDetachPath = r.URL.Path |
| json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved}) |
| + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/domains/"): |
| + // The per-host claim door, which carries the published prefix |
| + // map. Matched BEFORE the catch-all list GET below, which would |
| + // otherwise answer it with a list body and no `paths` key at all |
| + // — making an assertion about the map impossible to distinguish |
| + // from a client that never read it. |
| + fc.lastPathsPath = r.URL.Path |
| + body := map[string]any{"domain": map[string]any{"host": "tickets.example.com"}} |
| + if fc.paths != nil { |
| + body["paths"] = fc.paths |
| + } |
| + json.NewEncoder(w).Encode(body) |
| case r.Method == http.MethodGet: |
| json.NewEncoder(w).Encode(map[string]any{ |
| "domains": []map[string]any{ |
| @@ -586,6 +604,81 @@ func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) { |
| } |
| } |
| +// TestRunDomainsPathsPrintsThePublishedMap: the read surface for path |
| +// routing. It prints the map as PUBLISHED, sorted by prefix, and says in |
| +// words that this is desired state — because the other surface (`carlos |
| +// routes`) prints the rows a box DERIVED, and the difference between the two |
| +// is the whole diagnostic: the reconcile pass drops an entry whose target is |
| +// not a same-account route on that box, so a prefix visible here and not |
| +// there is a flip the deployment refused. |
| +func TestRunDomainsPathsPrintsThePublishedMap(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + // Deliberately out of alphabetical order in the map, and with one target |
| + // that is not a route of this deployment at all — the refused entry this |
| + // surface exists to make visible. |
| + fc.paths = map[string]string{ |
| + "paulca": "go.tito.io", |
| + "bigcorp": "not-deployed-here.example", |
| + "acme": "go.tito.io", |
| + } |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"paths", "-app", "tito", "ti.to"}) |
| + |
| + out := buf.String() |
| + // It asked the per-host claim door for the hostname it was given. |
| + if !strings.HasSuffix(fc.lastPathsPath, "/domains/ti.to") { |
| + t.Fatalf("asked for %q, want the per-host claim door for ti.to", fc.lastPathsPath) |
| + } |
| + for _, want := range []string{ |
| + "PREFIX", "TARGET", |
| + "/acme", "/bigcorp", "/paulca", |
| + "go.tito.io", "not-deployed-here.example", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing %q:\n%s", want, out) |
| + } |
| + } |
| + // Sorted by prefix: a map has no order, and a listing that reshuffles |
| + // between calls is one nobody can diff. |
| + acme, bigcorp, paulca := strings.Index(out, "/acme"), strings.Index(out, "/bigcorp"), strings.Index(out, "/paulca") |
| + if !(acme < bigcorp && bigcorp < paulca) { |
| + t.Fatalf("rows are not prefix-sorted (acme=%d bigcorp=%d paulca=%d):\n%s", acme, bigcorp, paulca, out) |
| + } |
| + // And it says which KIND of state this is. Without that line a reader |
| + // takes a published map for what is serving, which is the exact confusion |
| + // that makes a refused flip look like a working one. |
| + if !strings.Contains(out, "published desired state") || !strings.Contains(out, "carlos routes") { |
| + t.Fatalf("stdout does not distinguish published state from what a box serves:\n%s", out) |
| + } |
| +} |
| + |
| +// TestRunDomainsPathsSaysSoWhenThereAreNone: a hostname with no prefix map |
| +// prints a line, not a bare header. An older console sends no `paths` key at |
| +// all and must read the same way — "this hostname splits no traffic by path" |
| +// is the true answer for a deployment that has no path routing. |
| +func TestRunDomainsPathsSaysSoWhenThereAreNone(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + paths map[string]string |
| + }{ |
| + {"an empty published map", map[string]string{}}, |
| + {"a console that predates path routing", nil}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + fc := domainsCLIFixture(t) |
| + fc.paths = tc.paths |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"paths", "-app", "tito", "ti.to"}) |
| + |
| + if out := buf.String(); !strings.Contains(out, "splits no traffic by path") { |
| + t.Fatalf("stdout = %q, want a line saying there are none", out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| // ---- subprocess: the usage-error / bad-argument paths --------------------- |
| // TestDomainsCLIUsageErrors drives every domains verb with missing or |
| @@ -623,6 +716,8 @@ func TestDomainsCLIUsageErrors(t *testing.T) { |
| {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "no deployment target configured"}, |
| {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"}, |
| {"list no app", []string{"domains", "list"}, "no deployment target configured"}, |
| + {"paths no app", []string{"domains", "paths", "ti.to"}, "no deployment target configured"}, |
| + {"paths no positional host", []string{"domains", "paths", "-app", "tito"}, "usage: carlos domains paths"}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| out, err := run(tc.args...) |
diff --git a/internal/console/apidomainpaths_test.go b/internal/console/apidomainpaths_test.go| index ffb39de4a..014f747c0 100644 |
| --- a/internal/console/apidomainpaths_test.go |
| +++ b/internal/console/apidomainpaths_test.go |
| @@ -17,6 +17,7 @@ import ( |
| "encoding/json" |
| "fmt" |
| "net/http" |
| + "strings" |
| "testing" |
| "time" |
| @@ -222,6 +223,97 @@ func TestAPIDomainPathsRejectsBadPrefix(t *testing.T) { |
| } |
| } |
| +// TestAPIDomainPathsRejectsBadTarget is the OTHER half of the prefix map, and |
| +// the half that is easiest to get wrong in the handler rather than the store: a |
| +// malformed target hostname is the CALLER's error, so it must answer 422 |
| +// naming the entry — never the 502 "try again" that a store failure answers |
| +// with, which would tell the publishing platform to retry a body that will |
| +// fail identically forever. |
| +// |
| +// Pinned here at the REQUEST level as well as in |
| +// TestSetClaimPathsRejectsUnnormalisedTarget, because the two can fail |
| +// independently: the store can classify the error correctly while the door |
| +// drops it into the wrong branch, and dropping the %w wrap in claims.go is |
| +// enough to move every case in this test from 422 to 502. |
| +func TestAPIDomainPathsRejectsBadTarget(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-paths-badtarget") |
| + claimedHost(t, fx, member, "ti.to") |
| + for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "go tito io"} { |
| + code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, |
| + pathsBody(t, map[string]string{"paulca": target})) |
| + if code != http.StatusUnprocessableEntity { |
| + t.Fatalf("target %q = %d %s, want 422 — a malformed target is the caller's error, not ours", target, code, out) |
| + } |
| + // The offending ENTRY is named. With 10,000 prefixes in a published |
| + // map, a refusal that does not say which one is unactionable. |
| + if !strings.Contains(out, "paulca") || !strings.Contains(out, target) { |
| + t.Fatalf("target %q refusal = %s, want it to name the prefix and the target", target, out) |
| + } |
| + if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| + t.Fatalf("target %q: claim paths = %v, want none — a refused map must not partially land", target, c.Paths) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIDomainGetAnswersThePublishedMap is the READ half of the feature, and |
| +// the reason it cannot be left to `carlos routes`: that command lists the |
| +// route_paths rows a box DERIVED, and the reconcile pass silently drops any |
| +// entry whose target is not a route of the same account on that box. So an |
| +// entry the deployment REFUSED — a cross-tenant target, a target that is not |
| +// deployed here — appears on no registry-derived surface at all. This door |
| +// answers the map as PUBLISHED, which is the only place that refused entry is |
| +// visible, and the state an operator debugging a stalled migration is looking |
| +// for. |
| +// |
| +// Asserted with a target that is a plausible hostname but NOT a route of this |
| +// deployment, i.e. exactly the entry the pass would drop. It has to come back |
| +// regardless: this door reports what was published, and makes no judgement |
| +// about what a box did with it. |
| +func TestAPIDomainGetAnswersThePublishedMap(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-get-paths") |
| + claimedHost(t, fx, member, "ti.to") |
| + |
| + // Before anything is published: an object, never null, so a client can |
| + // loop without telling two spellings of "none" apart. |
| + if got := getPaths(t, fx, member, "ti.to"); got == nil || len(got) != 0 { |
| + t.Fatalf("paths on a fresh claim = %v (nil=%t), want an empty object", got, got == nil) |
| + } |
| + |
| + publishPaths(t, fx, member, "ti.to", map[string]string{ |
| + "paulca": "go.tito.io", |
| + "bigcorp": "not-deployed-here.example", |
| + }) |
| + got := getPaths(t, fx, member, "ti.to") |
| + if len(got) != 2 || got["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want both published entries", got) |
| + } |
| + if got["bigcorp"] != "not-deployed-here.example" { |
| + t.Fatalf("paths = %v, want the entry whose target is not a route here — a refused flip is exactly what this door exists to show", got) |
| + } |
| + |
| + // And it tracks a republish, so it can be used to check a rollback landed. |
| + publishPaths(t, fx, member, "ti.to", map[string]string{}) |
| + if got := getPaths(t, fx, member, "ti.to"); len(got) != 0 { |
| + t.Fatalf("paths after a clearing publish = %v, want none", got) |
| + } |
| +} |
| + |
| +// getPaths reads one hostname's published map off the per-host claim door. |
| +func getPaths(t *testing.T, fx *consoleFixture, token, host string) map[string]string { |
| + t.Helper() |
| + code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", host), token, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("get %s = %d %s, want 200", host, code, out) |
| + } |
| + var got struct { |
| + Paths map[string]string `json:"paths"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("get body %s: %v", out, err) |
| + } |
| + return got.Paths |
| +} |
| + |
| // TestAPIDomainPathsIsPublishOnly: publishing a prefix map decides where an |
| // account's traffic goes, which is a deployment-shaped act. The instance |
| // credential an app's own router holds reaches the attach and detach doors |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 4551c4e28..35bcdd10d 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -661,6 +661,17 @@ func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) { |
| }) |
| } |
| +// pathsOrEmpty renders a claim's prefix map for the wire as an object even |
| +// when it is absent. Claim.Paths is omitempty, so nil and empty are one |
| +// stored state; letting nil marshal to `null` would make a consumer tell two |
| +// spellings of the same answer apart before it could iterate. |
| +func pathsOrEmpty(paths map[string]string) map[string]string { |
| + if paths == nil { |
| + return map[string]string{} |
| + } |
| + return paths |
| +} |
| + |
| // handleAPIDomainDetach releases a hostname. Always 200, whatever was there: |
| // `removed` reports whether this call is what removed it, and the three ways |
| // of removing nothing — already gone, never claimed, claimed by someone else |
| @@ -962,6 +973,23 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) { |
| warmRecord(ctx, s.cfg.Releases, c.Host), s.dnsTargets(), now, |
| c.Wildcard && s.delegationZone != ""), |
| "deployment": s.domainCapability(ctx, acct.Sqid, app), |
| + // The prefix map as PUBLISHED, and this is the only surface that |
| + // answers for it. `carlos routes` shows the route_paths rows the |
| + // reconcile pass derived, which is ACTUAL state on one box — the pass |
| + // silently drops an entry whose target is not a same-account local |
| + // route, so an entry it refused appears on no other surface at all. |
| + // That is exactly the state an operator debugs, and a migration whose |
| + // refusals are invisible fails silently. |
| + // |
| + // A sibling key rather than a field on domainItem, deliberately: the |
| + // list door shares that struct, and a host at MaxPathsPerClaim would |
| + // put 10,000 entries into every row of `carlos domains list`. The |
| + // whole map belongs on the door that already narrows to one hostname. |
| + // |
| + // Always an object, never null: an absent map and an empty one are one |
| + // state (Claim.Paths is omitempty), and a client should not have to |
| + // tell a nil from a {} to loop over it. |
| + "paths": pathsOrEmpty(c.Paths), |
| }) |
| } |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| index 181587124..32168b8ed 100644 |
| --- a/internal/console/controlplane_test.go |
| +++ b/internal/console/controlplane_test.go |
| @@ -1278,3 +1278,86 @@ func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) { |
| } |
| claimOf(t, fx, "other.example.com") |
| } |
| + |
| +// TestControlPlaneAppRefusesAServicePathPublish is the prefix-map door's place |
| +// in this family. A published prefix map splits a hostname's traffic by leading |
| +// URL segment, and the reconcile pass admits any target route in the same |
| +// account — so a map on the CONTROL PLANE's own hostname hands a slice of the |
| +// console's URL space (`/admin`, `/api`) to whatever else that account runs. |
| +// That is not a decision a CI credential should be able to take. |
| +// |
| +// Service-only, like the attach above and unlike detach: a PERSON publishing a |
| +// prefix map for the control plane is an ordinary operator act. Only Publish |
| +// is exercised because it is the only role the route admits at all — an |
| +// instance credential never reaches the handler to be refused by this guard |
| +// (TestAPIDomainPathsIsPublishOnly pins that it gets a 403 from cliRoute |
| +// instead). |
| +func TestControlPlaneAppRefusesAServicePathPublish(t *testing.T) { |
| + const host = "docs.example.com" |
| + fx, person := cpDetachFixture(t, "code-cp-paths", host) |
| + u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host + "/paths" |
| + body := pathsBody(t, map[string]string{"paulca": "kass.carlosapp.dev"}) |
| + |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "kass", svcrole.Publish) |
| + status, _, out := rawReq(t, http.MethodPut, u, svc, body) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE publishing paths for the control plane = %d %s, want 403", status, out) |
| + } |
| + if !strings.Contains(out, "a person must") { |
| + t.Fatalf("refusal %q does not say a person is the principal this needs", out) |
| + } |
| + if c := claimOf(t, fx, host); len(c.Paths) != 0 { |
| + t.Fatalf("claim paths = %v, want none — a refused publish must not land", c.Paths) |
| + } |
| + |
| + // The guard sits BEFORE the hostname is normalized, so its answer cannot |
| + // vary with host SHAPE: a flagged app refuses a malformed host and a valid |
| + // one with the identical status and the identical body. Were the order |
| + // reversed, the 400 "invalid hostname" would reach a prober first and this |
| + // door would report whether a name is well-formed before reporting that |
| + // the app is off limits — a small oracle, but the same class as the ones |
| + // the domains family is built to close. |
| + // |
| + // Only shapes that actually REACH the handler are comparable here. A |
| + // segment of "." or ".." is collapsed by net/http's own path cleaning |
| + // before routing, so it lands on a different pattern entirely (405) and |
| + // says nothing about this guard's ordering. The percent-encoded traversal |
| + // does reach it, which is the case the normalization below the guard |
| + // exists for. |
| + for _, bad := range []string{"not a host", "no-dot", "%2e%2e%2fclaims", "UPPER.example.com."} { |
| + badStatus, _, badOut := rawReq(t, http.MethodPut, |
| + fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+bad+"/paths", svc, body) |
| + if badStatus != status || badOut != out { |
| + t.Fatalf("host %q answered %d %q; the flagged-app refusal must be constant across host shapes (%d %q)", |
| + bad, badStatus, badOut, status, out) |
| + } |
| + } |
| + |
| + // A non-member still meets the family's byte-identical 404, never the |
| + // refusal: the guard sits after domainsGate, so it is not an oracle for |
| + // which accounts hold a control-plane app. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-paths-eve") |
| + eveStatus, eveH, eveOut := rawReq(t, http.MethodPut, u, eve, body) |
| + assertNotFound(t, "non-member path publish on a flagged app", canonicalNotFound(t), eveStatus, eveH, eveOut) |
| + |
| + // The half that must keep working: a person publishes. |
| + if status, _, out := rawReq(t, http.MethodPut, u, person, body); status != http.StatusOK { |
| + t.Fatalf("a PERSON publishing paths for the control plane = %d %s, want 200", status, out) |
| + } |
| + if c := claimOf(t, fx, host); c.Paths["paulca"] != "kass.carlosapp.dev" { |
| + t.Fatalf("claim paths = %v, want the person's publish to have landed", c.Paths) |
| + } |
| + |
| + // The control: with the flag cleared, the identical service call lands. |
| + // The flag is the only variable between these two outcomes. |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + cleared := pathsBody(t, map[string]string{"bigcorp": "kass.carlosapp.dev"}) |
| + if status, _, out := rawReq(t, http.MethodPut, u, svc, cleared); status != http.StatusOK { |
| + t.Fatalf("a SERVICE publishing once the flag is cleared = %d %s, want 200", status, out) |
| + } |
| + if c := claimOf(t, fx, host); c.Paths["bigcorp"] != "kass.carlosapp.dev" { |
| + t.Fatalf("claim paths = %v, want the service's publish to have landed once the flag was cleared", c.Paths) |
| + } |
| +} |
diff --git a/internal/domains/paths_test.go b/internal/domains/paths_test.go| index 4c738a4e4..7e172915b 100644 |
| --- a/internal/domains/paths_test.go |
| +++ b/internal/domains/paths_test.go |
| @@ -116,9 +116,17 @@ func TestSetClaimPathsIsOwnerGated(t *testing.T) { |
| // TestSetClaimPathsRejectsUnnormalisedTarget: a target that is not a |
| // normalised hostname (no interior dot, wrong case, trailing dot, ...) is |
| -// refused with a validation error distinct from ErrHostUnavailable — this is |
| -// an owned, existing claim, so the failure can only be about the target |
| -// shape, not about who is asking or whether the host exists. |
| +// refused with ErrBadPathTarget — this is an owned, existing claim, so the |
| +// failure can only be about the target shape, not about who is asking or |
| +// whether the host exists. |
| +// |
| +// Asserted through the SENTINEL, not merely as "non-nil and not |
| +// ErrHostUnavailable". The API door answers this class as the CALLER's error |
| +// (422, naming the entry) rather than as a store failure (502, "try again"), |
| +// and errors.Is is the only thing that tells it which it has — so a lost %w |
| +// wrap would silently start telling the publishing platform to retry a body |
| +// that will fail identically forever. A non-nil check passes either way and |
| +// would not catch it. |
| func TestSetClaimPathsRejectsUnnormalisedTarget(t *testing.T) { |
| ctx := context.Background() |
| s := release.NewDirStore(t.TempDir()) |
| @@ -128,13 +136,23 @@ func TestSetClaimPathsRejectsUnnormalisedTarget(t *testing.T) { |
| }); err != nil { |
| t.Fatal(err) |
| } |
| - _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| - map[string]string{"paulca": "notahostname"}) |
| - if err == nil { |
| - t.Fatal("err = nil, want a validation error for an unnormalised target") |
| - } |
| - if errors.Is(err, ErrHostUnavailable) { |
| - t.Fatalf("err = %v, want a validation error, not ErrHostUnavailable", err) |
| + // Every shape NormalizeAliasHost turns away, and each one is something a |
| + // generated map could plausibly carry. |
| + for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "", "go tito io"} { |
| + _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": target}) |
| + if !errors.Is(err, ErrBadPathTarget) { |
| + t.Fatalf("target %q: err = %v, want ErrBadPathTarget", target, err) |
| + } |
| + if errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("target %q: err = %v, want the target-shape error, not ErrHostUnavailable", target, err) |
| + } |
| + // Refused WHOLE: a map with one bad entry stores none of it. |
| + if c, err := GetClaim(ctx, s, "ti.to"); err != nil { |
| + t.Fatal(err) |
| + } else if len(c.Paths) != 0 { |
| + t.Fatalf("target %q: claim paths = %v, want none", target, c.Paths) |
| + } |
| } |
| } |