| 1 | package console |
| 2 | |
| 3 | // The prefix-map door (apidomains.go): PUT .../domains/{host}/paths, the one |
| 4 | // way a claim's Paths map is written from outside a box. It is the door the |
| 5 | // TENANT'S OWN PLATFORM calls — that platform regenerates the whole map from |
| 6 | // its own database and publishes the result — so the properties under test |
| 7 | // are, in the order they'd hurt if lost: the write is WHOLESALE (a second |
| 8 | // publish replaces rather than merges, which is the only way a rollback can |
| 9 | // be expressed); a caller who does not hold the claim gets the family's |
| 10 | // byte-identical 404 rather than anything that distinguishes "someone else |
| 11 | // holds it" from "nobody does"; the two shape refusals are 422s that leave |
| 12 | // the stored map untouched, never a silently truncated write; and the door is |
| 13 | // Publish-scoped, so an instance credential cannot decide where an account's |
| 14 | // traffic goes. |
| 15 | |
| 16 | import ( |
| 17 | "encoding/json" |
| 18 | "fmt" |
| 19 | "net/http" |
| 20 | "strings" |
| 21 | "testing" |
| 22 | "time" |
| 23 | |
| 24 | "github.com/carlosframework/platform/internal/domains" |
| 25 | "github.com/carlosframework/platform/internal/svcrole" |
| 26 | ) |
| 27 | |
| 28 | func pathsURL(fx *consoleFixture, acct, app, host string) string { |
| 29 | return domainsURL(fx, acct, app) + "/" + host + "/paths" |
| 30 | } |
| 31 | |
| 32 | // pathsBody is the door's request shape. Always an OBJECT with a paths field |
| 33 | // rather than a bare map, so the body can grow a field later without every |
| 34 | // existing caller's body changing meaning. |
| 35 | func pathsBody(t *testing.T, paths map[string]string) string { |
| 36 | t.Helper() |
| 37 | b, err := json.Marshal(map[string]any{"paths": paths}) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | return string(b) |
| 42 | } |
| 43 | |
| 44 | // domainPathsResp is the answer: the NORMALIZED host (the one field the |
| 45 | // caller could not already know), how many prefixes were stored, and whether |
| 46 | // this call is what changed them. |
| 47 | type domainPathsResp struct { |
| 48 | Host string `json:"host"` |
| 49 | Prefixes int `json:"prefixes"` |
| 50 | Changed bool `json:"changed"` |
| 51 | } |
| 52 | |
| 53 | // publishPaths PUTs one map and returns the decoded answer, failing the test |
| 54 | // on any status but 200. |
| 55 | func publishPaths(t *testing.T, fx *consoleFixture, token, host string, paths map[string]string) domainPathsResp { |
| 56 | t.Helper() |
| 57 | code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), token, pathsBody(t, paths)) |
| 58 | if code != http.StatusOK { |
| 59 | t.Fatalf("publish paths on %s = %d %s, want 200", host, code, out) |
| 60 | } |
| 61 | var got domainPathsResp |
| 62 | if err := json.Unmarshal([]byte(out), &got); err != nil { |
| 63 | t.Fatalf("paths body %s: %v", out, err) |
| 64 | } |
| 65 | return got |
| 66 | } |
| 67 | |
| 68 | // claimedHost attaches the hostname a prefix map hangs off. Every test below |
| 69 | // starts from a claim this app actually holds, because this door writes no |
| 70 | // claim of its own — it only ever replaces one field of an existing one. |
| 71 | func claimedHost(t *testing.T, fx *consoleFixture, member, host string) { |
| 72 | t.Helper() |
| 73 | attachDomain(t, fx, member, host, "go.tito.io") |
| 74 | } |
| 75 | |
| 76 | // TestAPIDomainPathsPublishes: the door writes the map onto the claim, and |
| 77 | // the answer names the normalized host it wrote to. |
| 78 | func TestAPIDomainPathsPublishes(t *testing.T) { |
| 79 | fx, member := instanceFixture(t, "code-dom-paths-publish") |
| 80 | claimedHost(t, fx, member, "ti.to") |
| 81 | got := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| 82 | if got != (domainPathsResp{Host: "ti.to", Prefixes: 1, Changed: true}) { |
| 83 | t.Fatalf("publish answer = %+v, want ti.to with one prefix, changed", got) |
| 84 | } |
| 85 | c := claimOf(t, fx, "ti.to") |
| 86 | if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| 87 | t.Fatalf("claim paths = %v, want paulca -> go.tito.io", c.Paths) |
| 88 | } |
| 89 | // Republishing the identical map is the COMMON case — the caller |
| 90 | // republishes wholesale as a repair — and must not read as a change, or |
| 91 | // every repair would bump the serial and reload every edge in the fleet. |
| 92 | if again := publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}); again.Changed { |
| 93 | t.Fatalf("republishing the same map = %+v, want changed false", again) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // TestAPIDomainPathsReplaces is the wholesale contract: the caller |
| 98 | // regenerates its whole map and publishes it, so an entry it no longer lists |
| 99 | // must be GONE. Merging would make a rollback impossible to express. |
| 100 | func TestAPIDomainPathsReplaces(t *testing.T) { |
| 101 | fx, member := instanceFixture(t, "code-dom-paths-replace") |
| 102 | claimedHost(t, fx, member, "ti.to") |
| 103 | publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| 104 | publishPaths(t, fx, member, "ti.to", map[string]string{"bigcorp": "go.tito.io"}) |
| 105 | c := claimOf(t, fx, "ti.to") |
| 106 | if _, still := c.Paths["paulca"]; still { |
| 107 | t.Fatalf("claim paths = %v, want paulca gone — the second publish replaces, it does not merge", c.Paths) |
| 108 | } |
| 109 | if len(c.Paths) != 1 || c.Paths["bigcorp"] != "go.tito.io" { |
| 110 | t.Fatalf("claim paths = %v, want bigcorp alone", c.Paths) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // TestAPIDomainPathsEmptyBodyClears: an empty map is a legitimate published |
| 115 | // state — every account back on the origin — and is how a whole migration is |
| 116 | // rolled back in one call. |
| 117 | func TestAPIDomainPathsEmptyBodyClears(t *testing.T) { |
| 118 | fx, member := instanceFixture(t, "code-dom-paths-clear") |
| 119 | claimedHost(t, fx, member, "ti.to") |
| 120 | publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| 121 | got := publishPaths(t, fx, member, "ti.to", map[string]string{}) |
| 122 | if got != (domainPathsResp{Host: "ti.to", Prefixes: 0, Changed: true}) { |
| 123 | t.Fatalf("clear answer = %+v, want ti.to with no prefixes, changed", got) |
| 124 | } |
| 125 | if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| 126 | t.Fatalf("claim paths = %v, want none", c.Paths) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // TestAPIDomainPathsRequiresThePathsField: this write is WHOLESALE, so a body |
| 131 | // that names no map at all cannot be read as an empty one — a misspelled |
| 132 | // field would otherwise clear every prefix on the hostname and answer 200. |
| 133 | // An empty map still means "clear it"; it just has to be said. |
| 134 | func TestAPIDomainPathsRequiresThePathsField(t *testing.T) { |
| 135 | fx, member := instanceFixture(t, "code-dom-paths-nofield") |
| 136 | claimedHost(t, fx, member, "ti.to") |
| 137 | publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| 138 | |
| 139 | for _, body := range []string{`{}`, `{"pathz":{"bigcorp":"go.tito.io"}}`, `{"paths":null}`} { |
| 140 | code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, body) |
| 141 | if code != http.StatusBadRequest { |
| 142 | t.Fatalf("publish %s = %d %s, want 400", body, code, out) |
| 143 | } |
| 144 | c := claimOf(t, fx, "ti.to") |
| 145 | if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| 146 | t.Fatalf("claim paths after %s = %v, want the published map intact", body, c.Paths) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // TestAPIDomainPathsRejectsOtherAccount: a hostname another account holds |
| 152 | // answers with the bytes http.NotFound writes and nothing else. Not a 403, |
| 153 | // which would confirm the hostname exists; not a distinct JSON shape, which |
| 154 | // would itself be the tell. The caller here is an honest owner of its OWN |
| 155 | // account and app, so what refuses it is the claim's ownership check rather |
| 156 | // than the membership gate above it. |
| 157 | func TestAPIDomainPathsRejectsOtherAccount(t *testing.T) { |
| 158 | fx, member := instanceFixture(t, "code-dom-paths-other") |
| 159 | claimedHost(t, fx, member, "ti.to") |
| 160 | // The positive control, and it is not optional: an UNREGISTERED route |
| 161 | // answers the byte-identical 404 too, so without a publish that works |
| 162 | // this test would pass against a console that has no such door at all. |
| 163 | publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| 164 | |
| 165 | // A second account with an app of its own, and its owner's token. |
| 166 | now := time.Now() |
| 167 | if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | if _, err := fx.st.CreateAppIn(42, "shop", "bob@keymail.dev", now); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | bob := cliToken(t, fx, "bob@keymail.dev", "code-dom-paths-bob") |
| 174 | |
| 175 | want := canonicalNotFound(t) |
| 176 | code, h, body := rawReq(t, http.MethodPut, |
| 177 | fx.ts.URL+"/api/cli/apps/cdef/shop/domains/ti.to/paths", bob, |
| 178 | pathsBody(t, map[string]string{"bigcorp": "evil.example"})) |
| 179 | assertNotFound(t, "publishing paths onto another account's hostname", want, code, h, body) |
| 180 | // And nothing was written through it: the holder's own map is intact. |
| 181 | c := claimOf(t, fx, "ti.to") |
| 182 | if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| 183 | t.Fatalf("claim paths = %v, want the holder's own map — a stranger's publish must not land", c.Paths) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // TestAPIDomainPathsRejectsOversize: a map above the cap is refused WHOLE. A |
| 188 | // silently truncated map would route a subset of accounts and still look |
| 189 | // successful, which is the worst failure this feature can have. |
| 190 | func TestAPIDomainPathsRejectsOversize(t *testing.T) { |
| 191 | fx, member := instanceFixture(t, "code-dom-paths-oversize") |
| 192 | claimedHost(t, fx, member, "ti.to") |
| 193 | publishPaths(t, fx, member, "ti.to", map[string]string{"paulca": "go.tito.io"}) |
| 194 | |
| 195 | big := make(map[string]string, domains.MaxPathsPerClaim+1) |
| 196 | for i := 0; i <= domains.MaxPathsPerClaim; i++ { |
| 197 | big[fmt.Sprintf("acct%d", i)] = "go.tito.io" |
| 198 | } |
| 199 | code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, pathsBody(t, big)) |
| 200 | if code != http.StatusUnprocessableEntity { |
| 201 | t.Fatalf("oversize publish = %d %s, want 422", code, out) |
| 202 | } |
| 203 | c := claimOf(t, fx, "ti.to") |
| 204 | if len(c.Paths) != 1 || c.Paths["paulca"] != "go.tito.io" { |
| 205 | t.Fatalf("claim paths = %v, want the previous map untouched", c.Paths) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // TestAPIDomainPathsRejectsBadPrefix: the edge matches a request's FIRST path |
| 210 | // segment by equality, so a key with a slash in it could never match and must |
| 211 | // never be stored. "/paulca" is the shape a caller reaches for first, so it |
| 212 | // is the one worth pinning. |
| 213 | func TestAPIDomainPathsRejectsBadPrefix(t *testing.T) { |
| 214 | fx, member := instanceFixture(t, "code-dom-paths-badprefix") |
| 215 | claimedHost(t, fx, member, "ti.to") |
| 216 | code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, |
| 217 | pathsBody(t, map[string]string{"/paulca": "go.tito.io"})) |
| 218 | if code != http.StatusUnprocessableEntity { |
| 219 | t.Fatalf("leading-slash prefix = %d %s, want 422", code, out) |
| 220 | } |
| 221 | if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| 222 | t.Fatalf("claim paths = %v, want none — a refused map must not partially land", c.Paths) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // TestAPIDomainPathsRejectsBadTarget is the OTHER half of the prefix map, and |
| 227 | // the half that is easiest to get wrong in the handler rather than the store: a |
| 228 | // malformed target hostname is the CALLER's error, so it must answer 422 |
| 229 | // naming the entry — never the 502 "try again" that a store failure answers |
| 230 | // with, which would tell the publishing platform to retry a body that will |
| 231 | // fail identically forever. |
| 232 | // |
| 233 | // Pinned here at the REQUEST level as well as in |
| 234 | // TestSetClaimPathsRejectsUnnormalisedTarget, because the two can fail |
| 235 | // independently: the store can classify the error correctly while the door |
| 236 | // drops it into the wrong branch, and dropping the %w wrap in claims.go is |
| 237 | // enough to move every case in this test from 422 to 502. |
| 238 | func TestAPIDomainPathsRejectsBadTarget(t *testing.T) { |
| 239 | fx, member := instanceFixture(t, "code-dom-paths-badtarget") |
| 240 | claimedHost(t, fx, member, "ti.to") |
| 241 | for _, target := range []string{"notahostname", "GO.tito.io", "go.tito.io.", "go tito io"} { |
| 242 | code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), member, |
| 243 | pathsBody(t, map[string]string{"paulca": target})) |
| 244 | if code != http.StatusUnprocessableEntity { |
| 245 | t.Fatalf("target %q = %d %s, want 422 — a malformed target is the caller's error, not ours", target, code, out) |
| 246 | } |
| 247 | // The offending ENTRY is named. With 10,000 prefixes in a published |
| 248 | // map, a refusal that does not say which one is unactionable. |
| 249 | if !strings.Contains(out, "paulca") || !strings.Contains(out, target) { |
| 250 | t.Fatalf("target %q refusal = %s, want it to name the prefix and the target", target, out) |
| 251 | } |
| 252 | if c := claimOf(t, fx, "ti.to"); len(c.Paths) != 0 { |
| 253 | t.Fatalf("target %q: claim paths = %v, want none — a refused map must not partially land", target, c.Paths) |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // TestAPIDomainGetAnswersThePublishedMap is the READ half of the feature, and |
| 259 | // the reason it cannot be left to `carlos routes`: that command lists the |
| 260 | // route_paths rows a box DERIVED, and the reconcile pass silently drops any |
| 261 | // entry whose target is not a route of the same account on that box. So an |
| 262 | // entry the deployment REFUSED — a cross-tenant target, a target that is not |
| 263 | // deployed here — appears on no registry-derived surface at all. This door |
| 264 | // answers the map as PUBLISHED, which is the only place that refused entry is |
| 265 | // visible, and the state an operator debugging a stalled migration is looking |
| 266 | // for. |
| 267 | // |
| 268 | // Asserted with a target that is a plausible hostname but NOT a route of this |
| 269 | // deployment, i.e. exactly the entry the pass would drop. It has to come back |
| 270 | // regardless: this door reports what was published, and makes no judgement |
| 271 | // about what a box did with it. |
| 272 | func TestAPIDomainGetAnswersThePublishedMap(t *testing.T) { |
| 273 | fx, member := instanceFixture(t, "code-dom-get-paths") |
| 274 | claimedHost(t, fx, member, "ti.to") |
| 275 | |
| 276 | // Before anything is published: an object, never null, so a client can |
| 277 | // loop without telling two spellings of "none" apart. |
| 278 | if got := getPaths(t, fx, member, "ti.to"); got == nil || len(got) != 0 { |
| 279 | t.Fatalf("paths on a fresh claim = %v (nil=%t), want an empty object", got, got == nil) |
| 280 | } |
| 281 | |
| 282 | publishPaths(t, fx, member, "ti.to", map[string]string{ |
| 283 | "paulca": "go.tito.io", |
| 284 | "bigcorp": "not-deployed-here.example", |
| 285 | }) |
| 286 | got := getPaths(t, fx, member, "ti.to") |
| 287 | if len(got) != 2 || got["paulca"] != "go.tito.io" { |
| 288 | t.Fatalf("paths = %v, want both published entries", got) |
| 289 | } |
| 290 | if got["bigcorp"] != "not-deployed-here.example" { |
| 291 | 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) |
| 292 | } |
| 293 | |
| 294 | // And it tracks a republish, so it can be used to check a rollback landed. |
| 295 | publishPaths(t, fx, member, "ti.to", map[string]string{}) |
| 296 | if got := getPaths(t, fx, member, "ti.to"); len(got) != 0 { |
| 297 | t.Fatalf("paths after a clearing publish = %v, want none", got) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // getPaths reads one hostname's published map off the per-host claim door. |
| 302 | func getPaths(t *testing.T, fx *consoleFixture, token, host string) map[string]string { |
| 303 | t.Helper() |
| 304 | code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", host), token, "") |
| 305 | if code != http.StatusOK { |
| 306 | t.Fatalf("get %s = %d %s, want 200", host, code, out) |
| 307 | } |
| 308 | var got struct { |
| 309 | Paths map[string]string `json:"paths"` |
| 310 | } |
| 311 | if err := json.Unmarshal([]byte(out), &got); err != nil { |
| 312 | t.Fatalf("get body %s: %v", out, err) |
| 313 | } |
| 314 | return got.Paths |
| 315 | } |
| 316 | |
| 317 | // TestAPIDomainPathsReachesPublishAndInstance is the door's role contract. |
| 318 | // |
| 319 | // Publish AND Instance, matching the READ of the same state beside it (GET |
| 320 | // .../domains/{host} carries both). An earlier revision was Publish-only on |
| 321 | // the reasoning that a flip is deployment-shaped; that left an instance |
| 322 | // credential able to READ a hostname's published prefix map and not write |
| 323 | // it, while apidomains.go's own header names titogo's router — calling this |
| 324 | // family with its CARLOS_INSTANCE_TOKEN — as the caller the door exists for. |
| 325 | // The widening crosses no tenancy boundary: the door is owner-gated on |
| 326 | // account and app and refuses a control-plane app, so an instance token can |
| 327 | // only publish its OWN app's map. |
| 328 | // |
| 329 | // Operate is the negative control: widening the door must widen it to a |
| 330 | // named pair, not to every service role. |
| 331 | func TestAPIDomainPathsReachesPublishAndInstance(t *testing.T) { |
| 332 | fx, member := instanceFixture(t, "code-dom-paths-roles") |
| 333 | claimedHost(t, fx, member, "ti.to") |
| 334 | body := pathsBody(t, map[string]string{"paulca": "go.tito.io"}) |
| 335 | |
| 336 | inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| 337 | if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), inst, body); code != http.StatusOK { |
| 338 | t.Fatalf("instance-role publish = %d %s, want 200 — the router that reads this map must be able to write it", code, out) |
| 339 | } |
| 340 | pub := serviceToken(t, fx.st, OpsAccountID, "hub", "titogo", svcrole.Publish) |
| 341 | if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), pub, body); code != http.StatusOK { |
| 342 | t.Fatalf("publish-role publish = %d %s, want 200", code, out) |
| 343 | } |
| 344 | op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) |
| 345 | if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", "ti.to"), op, body); code != http.StatusForbidden { |
| 346 | t.Fatalf("operate-role publish = %d %s, want 403 — the door is two named roles, not every service role", code, out) |
| 347 | } |
| 348 | } |
| 349 | |