package console // The prefix-map door (apidomains.go): PUT .../domains/{host}/paths, the one // way a claim's Paths map is written from outside a box. It is the door the // TENANT'S OWN PLATFORM calls — that platform regenerates the whole map from // its own database and publishes the result — so the properties under test // are, in the order they'd hurt if lost: the write is WHOLESALE (a second // publish replaces rather than merges, which is the only way a rollback can // be expressed); a caller who does not hold the claim gets the family's // byte-identical 404 rather than anything that distinguishes "someone else // holds it" from "nobody does"; the two shape refusals are 422s that leave // the stored map untouched, never a silently truncated write; and the door is // Publish-scoped, so an instance credential cannot decide where an account's // traffic goes. import ( "encoding/json" "fmt" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/svcrole" ) func pathsURL(fx *consoleFixture, acct, app, host string) string { return domainsURL(fx, acct, app) + "/" + host + "/paths" } // pathsBody is the door's request shape. Always an OBJECT with a paths field // rather than a bare map, so the body can grow a field later without every // existing caller's body changing meaning. func pathsBody(t *testing.T, paths map[string]string) string { t.Helper() b, err := json.Marshal(map[string]any{"paths": paths}) if err != nil { t.Fatal(err) } return string(b) } // domainPathsResp is the answer: the NORMALIZED host (the one field the // caller could not already know), how many prefixes were stored, and whether // this call is what changed them. type domainPathsResp struct { Host string `json:"host"` Prefixes int `json:"prefixes"` Changed bool `json:"changed"` } // publishPaths PUTs one map and returns the decoded answer, failing the test // on any status but 200. func publishPaths(t *testing.T, fx *consoleFixture, token, host string, paths map[string]string) domainPathsResp { t.Helper() code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), token, pathsBody(t, paths)) if code != http.StatusOK { t.Fatalf("publish paths on %s = %d %s, want 200", host, code, out) } var got domainPathsResp if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("paths body %s: %v", out, err) } return got } // claimedHost attaches the hostname a prefix map hangs off. Every test below // starts from a claim this app actually holds, because this door writes no // claim of its own — it only ever replaces one field of an existing one. func claimedHost(t *testing.T, fx *consoleFixture, member, host string) { t.Helper() attachDomain(t, fx, member, host, "go.tito.io") } // TestAPIDomainPathsPublishes: the door writes the map onto the claim, and // the answer names the normalized host it wrote to. func TestAPIDomainPathsPublishes(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-publish") claimedHost(t, fx, member, "ti.to") got := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) if got != (domainPathsResp{Host: "ti.to", Prefixes: 1, Changed: true}) { t.Fatalf("publish answer = %+v, want ti.to with one prefix, changed", got) } c := claimOf(t, fx, "ti.to") if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { t.Fatalf("claim paths = %v, want paulca -> go.tito.io", c.Paths) } // Republishing the identical map is the COMMON case — the caller // republishes wholesale as a repair — and must not read as a change, or // every repair would bump the serial and reload every edge in the fleet. if again := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}); again.Changed { t.Fatalf("republishing the same map = %+v, want changed false", again) } } // TestAPIDomainPathsReplaces is the wholesale contract: the caller // regenerates its whole map and publishes it, so an entry it no longer lists // must be GONE. Merging would make a rollback impossible to express. func TestAPIDomainPathsReplaces(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-replace") claimedHost(t, fx, member, "ti.to") publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) publishPaths(t, fx, member, "ti.to", map[string]string{"bigcorp": "go.tito.io"}) c := claimOf(t, fx, "ti.to") if _, still := c.Paths["paulca"]; still { t.Fatalf("claim paths = %v, want paulca gone — the second publish replaces, it does not merge", c.Paths) } if len(c.Paths) != 1 || c.Paths["bigcorp"] != "go.tito.io" { t.Fatalf("claim paths = %v, want bigcorp alone", c.Paths) } } // TestAPIDomainPathsEmptyBodyClears: an empty map is a legitimate published // state — every account back on the origin — and is how a whole migration is // rolled back in one call. func TestAPIDomainPathsEmptyBodyClears(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-clear") claimedHost(t, fx, member, "ti.to") publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) got := publishPaths(t, fx, member, "ti.to", map[string]string{}) if got != (domainPathsResp{Host: "ti.to", Prefixes: 0, Changed: true}) { t.Fatalf("clear answer = %+v, want ti.to with no prefixes, changed", got) } if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { t.Fatalf("claim paths = %v, want none", c.Paths) } } // TestAPIDomainPathsRequiresThePathsField: this write is WHOLESALE, so a body // that names no map at all cannot be read as an empty one — a misspelled // field would otherwise clear every prefix on the hostname and answer 200. // An empty map still means "clear it"; it just has to be said. func TestAPIDomainPathsRequiresThePathsField(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-nofield") claimedHost(t, fx, member, "ti.to") publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) for _, body := range []string{`{}`, `{"pathz":{"bigcorp":"go.tito.io"}}`, `{"paths":null}`} { code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, body) if code != http.StatusBadRequest { t.Fatalf("publish %s = %d %s, want 400", body, code, out) } c := claimOf(t, fx, "ti.to") if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { t.Fatalf("claim paths after %s = %v, want the published map intact", body, c.Paths) } } } // TestAPIDomainPathsRejectsOtherAccount: a hostname another account holds // answers with the bytes http.NotFound writes and nothing else. Not a 403, // which would confirm the hostname exists; not a distinct JSON shape, which // would itself be the tell. The caller here is an honest owner of its OWN // account and app, so what refuses it is the claim's ownership check rather // than the membership gate above it. func TestAPIDomainPathsRejectsOtherAccount(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-other") claimedHost(t, fx, member, "ti.to") // The positive control, and it is not optional: an UNREGISTERED route // answers the byte-identical 404 too, so without a publish that works // this test would pass against a console that has no such door at all. publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) // A second account with an app of its own, and its owner's token. now := time.Now() if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := fx.st.CreateAppIn(42, "shop", "bob@keymail.dev", now); err != nil { t.Fatal(err) } bob := cliToken(t, fx, "bob@keymail.dev", "code-dom-paths-bob") want := canonicalNotFound(t) code, h, body := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/cdef/shop/domains/ti.to/paths", bob, pathsBody(t, map[string]string{"bigcorp": "evil.example"})) assertNotFound(t, "publishing paths onto another account's hostname", want, code, h, body) // And nothing was written through it: the holder's own map is intact. c := claimOf(t, fx, "ti.to") if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { t.Fatalf("claim paths = %v, want the holder's own map — a stranger's publish must not land", c.Paths) } } // TestAPIDomainPathsRejectsOversize: a map above the cap is refused WHOLE. A // silently truncated map would route a subset of accounts and still look // successful, which is the worst failure this feature can have. func TestAPIDomainPathsRejectsOversize(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-oversize") claimedHost(t, fx, member, "ti.to") publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) big := make(map[string]string, domains.MaxPathsPerClaim+1) for i := 0; i <= domains.MaxPathsPerClaim; i++ { big[fmt.Sprintf("acct%d", i)] = "go.tito.io" } code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, pathsBody(t, big)) if code != http.StatusUnprocessableEntity { t.Fatalf("oversize publish = %d %s, want 422", code, out) } c := claimOf(t, fx, "ti.to") if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { t.Fatalf("claim paths = %v, want the previous map untouched", c.Paths) } } // TestAPIDomainPathsRejectsBadPrefix: the edge matches a request's FIRST path // segment by equality, so a key with a slash in it could never match and must // never be stored. "/paulca" is the shape a caller reaches for first, so it // is the one worth pinning. func TestAPIDomainPathsRejectsBadPrefix(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-badprefix") claimedHost(t, fx, member, "ti.to") code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, pathsBody(t, map[string]string{"/paulca": "go.tito.io"})) if code != http.StatusUnprocessableEntity { t.Fatalf("leading-slash prefix = %d %s, want 422", code, out) } if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { t.Fatalf("claim paths = %v, want none — a refused map must not partially land", c.Paths) } } // 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 } // TestAPIDomainPathsReachesPublishAndInstance is the door's role contract. // // Publish AND Instance, matching the READ of the same state beside it (GET // .../domains/{host} carries both). An earlier revision was Publish-only on // the reasoning that a flip is deployment-shaped; that left an instance // credential able to READ a hostname's published prefix map and not write // it, while apidomains.go's own header names titogo's router — calling this // family with its CARLOS_INSTANCE_TOKEN — as the caller the door exists for. // The widening crosses no tenancy boundary: the door is owner-gated on // account and app and refuses a control-plane app, so an instance token can // only publish its OWN app's map. // // Operate is the negative control: widening the door must widen it to a // named pair, not to every service role. func TestAPIDomainPathsReachesPublishAndInstance(t *testing.T) { fx, member := instanceFixture(t, "code-dom-paths-roles") claimedHost(t, fx, member, "ti.to") body := pathsBody(t, map[string]string{"paulca": "go.tito.io"}) inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), inst, body); code != http.StatusOK { t.Fatalf("instance-role publish = %d %s, want 200 — the router that reads this map must be able to write it", code, out) } pub := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish) if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), pub, body); code != http.StatusOK { t.Fatalf("publish-role publish = %d %s, want 200", code, out) } op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), op, body); code != http.StatusForbidden { t.Fatalf("operate-role publish = %d %s, want 403 — the door is two named roles, not every service role", code, out) } }