carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
16import (
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
28func 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.
35func 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.
47type 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.
55func 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.
71func 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.
78func 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.
100func 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.
117func 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.
134func 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.
157func 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.
190func 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.
213func 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.
238func 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.
272func 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.
302func 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.
331func 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