carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// Task 2: the store door — the ACL is the whole security story, so it is
4// tested as a matrix BEFORE any HTTP shape exists: every key class from the
5// spec's #73 inventory × operation × own/other tenancy, with the exact keys a
6// real box uses. A row that flips here is a tenancy breach, not a test nit.
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "go/ast"
14 "go/parser"
15 "go/token"
16 "io"
17 "net/http"
18 "net/http/httptest"
19 "os"
20 "path/filepath"
21 "slices"
22 "strings"
23 "testing"
24
25 "github.com/carlosframework/platform/internal/domains"
26 "github.com/carlosframework/platform/internal/plans"
27 "github.com/carlosframework/platform/internal/registry"
28 "github.com/carlosframework/platform/internal/release"
29 "time"
30)
31
32// The two identities every row is judged against: fleet "pi" owned by account
33// sqid "ab12". "OTHER"/"zz99" is any other tenant.
34const (
35 testSqid = "ab12"
36 testFleet = "pi"
37)
38
39type aclOp int
40
41const (
42 opRead aclOp = iota
43 opWrite
44 opDelete
45)
46
47func (o aclOp) String() string {
48 switch o {
49 case opRead:
50 return "read"
51 case opWrite:
52 return "write"
53 default:
54 return "delete"
55 }
56}
57
58// aclAllows is the door's decision for one (key, op), canonicalization
59// included — exactly the order the handler runs it in: canonicalKey first, so
60// a traversal key is refused before any ACL string ever sees it.
61func aclAllows(key string, op aclOp) bool {
62 k, ok := canonicalKey(key)
63 if !ok {
64 return false
65 }
66 if op == opRead {
67 return aclRead(testSqid, testFleet, k)
68 }
69 return aclWrite(testSqid, testFleet, k)
70}
71
72func TestEntitlementsAreNotReadableThroughTheEdgeDoor(t *testing.T) {
73 for _, key := range []string{
74 "store/entitlements/" + testSqid + ".json",
75 "store/entitlements/other.json",
76 "accounts/" + testSqid + "/../../store/entitlements/" + testSqid + ".json",
77 "apps/" + testSqid + "/../../store/entitlements/" + testSqid + ".json",
78 } {
79 for _, op := range []aclOp{opRead, opWrite, opDelete} {
80 if aclAllows(key, op) {
81 t.Errorf("%s allowed for %q", op, key)
82 }
83 }
84 }
85 for _, prefix := range []string{"store/", "store/entitlements/", "store/entitlements/" + testSqid} {
86 if _, ok := aclListScope(testSqid, testFleet, prefix); ok {
87 t.Errorf("listing allowed for %q", prefix)
88 }
89 if aclDir(testSqid, testFleet, prefix) {
90 t.Errorf("directory visible: %q", prefix)
91 }
92 }
93}
94
95func TestStoreDoorACLMatrix(t *testing.T) {
96 rows := []struct {
97 key string
98 op aclOp
99 want bool
100 why string
101 }{
102 // --- the shared platform reads the spec allowlists (F3, F7) ---
103 {"control/serial", opRead, true, "the 2s convergence poll"},
104 {"control/serial", opWrite, false, "a box must never bump the platform serial"},
105 {"control/serial", opDelete, false, "nor delete it"},
106 {"bootstrap/carlos", opRead, true, "F7: the sanctioned self-update path"},
107 {"bootstrap/carlos", opWrite, false, "a box must never publish the platform binary"},
108 {"control/serial2", opRead, false, "the allowlist is exact, not a prefix"},
109 {"control/", opRead, false, "no listing-shaped key stands in for the allowlist"},
110 {"bootstrap/carlos.sig", opRead, false, "exact key only"},
111 {"control/system/router/stable.json", opRead, true, "the platform channel pointer (system update spec §3)"},
112 {"control/system/router/canary.json", opRead, true, "…and the canary one"},
113 {"control/system/router/stable.json", opWrite, false, "a box never writes a pointer"},
114 {"control/system/router", opRead, false, "the pointer shape is exactly three segments ending .json"},
115 {"control/system/router/stable.json/x", opRead, false, "…not four"},
116 {"control/system/router/stable.txt", opRead, false, "…and .json"},
117 {"bootstrap/router/v0.18.0/carlos-linux-arm64", opRead, true, "a mirrored platform artifact"},
118 {"bootstrap/router/v0.18.0/carlos-linux-arm64", opWrite, false, "a box must never publish the platform binary"},
119 {"bootstrap/router/v0.18.0", opRead, false, "the artifact shape is exactly three segments"},
120 {"bootstrap/router/v0.18.0/a/b", opRead, false, "…not four"},
121 {"bootstrap/router.sig", opRead, false, "nothing else under bootstrap/"},
122 {"bootstrap/router/../../x", opRead, false, "traversal is refused by canonicalKey before the ACL"},
123 {"control/system/../serial", opRead, false, "…on both prefixes"},
124 {release.LayoutIntentKey(), opRead, true, "every box needs the fleet's layout intent to converge"},
125 {release.LayoutIntentKey(), opWrite, false, "a box must never author the fleet's layout intent"},
126 {release.LayoutIntentKey(), opDelete, false, "nor delete it"},
127 {"control/layout.json2", opRead, false, "the allowlist is exact, not a prefix"},
128
129 // --- own account estate ---
130 // The account manifest is accounts/<sqid>/account.json
131 // (domains.AccountManifestKey), NOT accounts/<sqid>.json: a box that
132 // cannot read it sees zero accounts, and provision.Reconcile then
133 // retires every provisioned route on the box.
134 {"accounts/ab12/account.json", opRead, true, "own account manifest"},
135 {"accounts/zz99/account.json", opRead, false, "another tenant's account manifest"},
136 {"accounts/ab12/account.json", opWrite, false, "reads only, even of its own account"},
137 {"accounts/ab12.json", opRead, false, "the manifest is a directory deeper; this key is not the account"},
138 {"accounts/ab12x/account.json", opRead, false, "sqid prefix must not match a longer sqid"},
139 {"accounts/ab12", opRead, false, "the account prefix itself is not an object"},
140 {"apps/ab12/web/channels/stable.json", opRead, true, "own app channel pointer"},
141 {"apps/ab12/web/releases/v1.2.3/manifest.json", opRead, true, "own release manifest"},
142 {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opRead, true, "own artifact (F8: streamed, not presigned)"},
143 {"apps/ab12/web/config/production.json", opRead, true, "own config bundle"},
144 {"apps/ab12/web/restart-gen.json", opRead, true, "own restart generation"},
145 {"apps/ab12/web/channels/stable.json", opWrite, false, "a box never promotes"},
146 {"apps/ab12/web/releases/v1.2.3/app.tar.gz", opDelete, false, "retention is not a box's job (F6)"},
147
148 // --- own account's app LOGS: the second (and only other) write scope ---
149 // applog.ChunkKey's shape, which is what logshipLoop PUTs and what its
150 // hourly prune DELETEs. The rows below the allows are the regression
151 // pins that keep this from becoming a blanket apps/<sqid>/** grant.
152 {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opWrite, true, "own app's log chunk"},
153 {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opDelete, true, "logship prunes its own subtree hourly"},
154 {"apps/ab12/web/logs/box-a/2026081108/0000000000000001-42.jsonl.gz", opRead, true, "reads already covered by the app estate"},
155 {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opWrite, false, "another account's app logs"},
156 {"apps/zz99/web/logs/box-a/2026081108/x.jsonl.gz", opDelete, false, "nor delete them"},
157 {"apps/ab12x/web/logs/box-a/x.jsonl.gz", opWrite, false, "sqid prefix must not match a longer sqid"},
158 {"apps/ab12/web/instances/h.example.json", opWrite, false, "REGRESSION: the logs grant must not mint instance records"},
159 {"apps/ab12/web/config/production.json", opWrite, false, "REGRESSION: nor rewrite config"},
160 {"apps/ab12/web/restart-gen.json", opWrite, false, "REGRESSION: nor request its own restarts"},
161 {"apps/ab12/web/releases/v1.2.3/manifest.json", opWrite, false, "REGRESSION: nor publish a release"},
162 {"apps/ab12/logs/box-a/x.jsonl.gz", opWrite, false, "logs must sit under an app segment, not the account root"},
163 {"apps/ab12/web/logs", opWrite, false, "the logs prefix itself is not an object"},
164 {"apps/ab12/web/logs/", opWrite, false, "nor is the bare prefix with a slash"},
165 {"apps/ab12/web/channels/logs/stable.json", opWrite, false, "\"logs\" deeper in the key grants nothing"},
166 {"apps/ab12/weblogs/box-a/x.jsonl.gz", opWrite, false, "the segment is logs/, not a suffix of the app name"},
167 {"apps/ab12/web/logs/../../zz99/web/logs/x.jsonl.gz", opWrite, false, "traversal out of the logs subtree"},
168 {"apps/ab12/web/logs/../channels/stable.json", opWrite, false, "traversal out of logs into channels"},
169
170 // --- sibling chunk families (errors-and-analytics spec §3.2) ---
171 {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opWrite, true, "own app's error chunk"},
172 {"apps/ab12/web/errors/box-a/2026090710/0000000000000001-42.jsonl.gz", opDelete, true, "errship prunes its own subtree hourly"},
173 {"apps/ab12/web/analytics/box-a/days/2026-09-07.json", opWrite, true, "own app's analytics day file"},
174 {"apps/zz99/web/errors/box-a/x.jsonl.gz", opWrite, false, "another account's error chunks"},
175 {"apps/ab12/errors/box-a/x.jsonl.gz", opWrite, false, "errors must sit under an app segment"},
176 {"apps/ab12/web/errors", opWrite, false, "the bare family segment is not a chunk"},
177 {"apps/ab12/web/errors/", opWrite, false, "nor with a slash"},
178 {"apps/ab12/web/other/box-a/x.jsonl.gz", opWrite, false, "the family list is exact: logs, errors, analytics"},
179 {"apps/ab12/web/channels/errors/stable.json", opWrite, false, "\"errors\" deeper in the key grants nothing"},
180 {"apps/ab12/web/errorsx/box-a/x.jsonl.gz", opWrite, false, "the segment is errors/, not a prefix of a longer name"},
181
182 // --- own account's promotion records (settings spec §4.1, ruling R17) ---
183 // Signed digests of the account's own builds, which HeldAuthorized
184 // reads on a fleet box. One shape, exactly five segments; nothing else
185 // under releases/ and never a write.
186 {"releases/ab12/web/promoted/v1.2.3", opRead, true, "own account's promotion record"},
187 {"releases/zz99/web/promoted/v1.2.3", opRead, false, "another account's promotion record"},
188 {"releases/ab12x/web/promoted/v1.2.3", opRead, false, "sqid must match exactly, not as a prefix"},
189 {"releases/ab12/web/promoted/v1.2.3/x", opRead, false, "a sixth segment"},
190 {"releases/ab12/web/promoted", opRead, false, "a missing version"},
191 {"releases/ab12/web/promoted/", opRead, false, "an empty version"},
192 {"releases/ab12/pointer-digests", opRead, false, "the account's digest marker is not granted"},
193 {"releases/ab12/web/other/v1.2.3", opRead, false, "segment 3 is exactly promoted"},
194 {"releases/ab12/Web!/promoted/v1.2.3", opRead, false, "an invalid app"},
195 {"releases/ab12/web/promoted/v1~bad", opRead, false, "an invalid version"},
196 {"releases/ab12/web/promoted/v1.2.3", opWrite, false, "a box never writes a promotion record"},
197 {"releases/ab12/web/promoted/v1.2.3", opDelete, false, "nor deletes one"},
198 {"releases/ab12/web/promoted/../../zz99/web/promoted/v1", opRead, false, "traversal out of own account"},
199
200 // --- other tenants ---
201 {"apps/zz99/web/channels/stable.json", opRead, false, "another account's app"},
202 {"apps/ab12x/web/channels/stable.json", opRead, false, "sqid prefix must not match a longer sqid"},
203 {"apps/ab1/web/channels/stable.json", opRead, false, "nor a shorter one"},
204 {"apps/ab12", opRead, false, "the account prefix itself is not an object"},
205
206 // --- own fleet corner: the only writable place there is ---
207 {"fleets/pi/bindings/app.example.json", opRead, true, "the binding takeover guard reads before it CASes"},
208 {"fleets/pi/bindings/app.example.json", opWrite, true, "fleet bindings are the box's own message"},
209 {"fleets/pi/status/boxes/box-a.json", opWrite, true, "own heartbeat"},
210 {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opWrite, true, "own metrics"},
211 {"fleets/pi/metrics/boxes/box-a/raw/1699.json", opDelete, true, "own 48h metrics prune"},
212 {"fleets/other/status/boxes/box-a.json", opRead, false, "another fleet's corner"},
213 {"fleets/other/status/boxes/box-a.json", opWrite, false, "and it cannot write there"},
214 {"fleets/other/status/boxes/box-a.json", opDelete, false, "nor delete there"},
215 {"fleets/pix/status/boxes/box-a.json", opWrite, false, "fleet prefix must not match a longer fleet name"},
216 {"fleets/pi", opWrite, false, "the fleet prefix itself is not an object"},
217
218 // --- domains/ claims: never through aclRead (#215) — serveClaim
219 // decides per claim body, own account and verified only (see
220 // TestStoreDoorClaimsAreOwnAccountAndRedacted); console-only write ---
221 {"domains/app.example.com.json", opRead, false, "claims are served by serveClaim, not the prefix ACL"},
222 {"domains/app.example.com.json", opWrite, false, "boxes read claims, only the console writes them"},
223 {"domains/app.example.com.json", opDelete, false, "nor delete them"},
224 {"domains", opRead, false, "an object AT the bare name, not the claims prefix"},
225
226 // --- the self-serve storage entitlement is console-only (#381) ---
227 // store/entitlements/<sqid>.json carries the applicant's note, the
228 // deciding operator's address and the decline REASON — an operator's
229 // commentary about a tenant. It lives outside accounts/ precisely so
230 // that no fleet credential can read it; had it gone to the obvious
231 // accounts/<sqid>/store.json, the account's own boxes could.
232 {"store/entitlements/" + testSqid + ".json", opRead, false, "a box never reads its account's entitlement, own sqid or not"},
233 {"store/entitlements/zz99.json", opRead, false, "still less another account's"},
234 {"store/entitlements/" + testSqid + ".json", opWrite, false, "and never writes one"},
235 {"store/entitlements/" + testSqid + ".json", opDelete, false, "nor deletes one"},
236 {"store/", opRead, false, "no listing-shaped key reaches the prefix either"},
237 {"store", opRead, false, "nor an object at the bare name"},
238
239 // --- shapes canonicalKey must kill before the ACL is consulted ---
240 {"apps/ab12/../zz99/web/channels/stable.json", opRead, false, "traversal out of own account"},
241 {"fleets/pi/../other/status/boxes/box-a.json", opWrite, false, "traversal out of own fleet"},
242 {"../etc/passwd", opRead, false, "traversal below the keyspace"},
243 {"/control/serial", opRead, false, "leading slash"},
244 {"apps//ab12/web/channels/stable.json", opRead, false, "empty segment"},
245 {"apps/ab12/./web/channels/stable.json", opRead, false, "dot segment"},
246 {"", opRead, false, "empty key"},
247 }
248 for _, row := range rows {
249 got := aclAllows(row.key, row.op)
250 if got != row.want {
251 t.Errorf("acl %s %q = %v, want %v (%s)", row.op, row.key, got, row.want, row.why)
252 }
253 }
254}
255
256// TestAclReadLayoutIntentNeedsBothIdentities pins the same empty-sqid,
257// empty-fleet guard aclRead already applies to every other key: an
258// authenticated identity is required before the allowlist is even
259// consulted, so a request that never resolved a fleet row cannot read the
260// layout intent either.
261func TestAclReadLayoutIntentNeedsBothIdentities(t *testing.T) {
262 key := release.LayoutIntentKey()
263 if !aclRead(testSqid, testFleet, key) {
264 t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key)
265 }
266 if aclRead("", testFleet, key) {
267 t.Errorf("aclRead(\"\", %q, %q) = true, want false", testFleet, key)
268 }
269 if aclRead(testSqid, "", key) {
270 t.Errorf("aclRead(%q, \"\", %q) = true, want false", testSqid, key)
271 }
272 if aclWrite(testSqid, testFleet, key) {
273 t.Errorf("aclWrite(%q, %q, %q) = true, want false", testSqid, testFleet, key)
274 }
275}
276
277// TestCanonicalKeyRefusesShapes pins the pre-ACL gate on its own: the ACL is
278// prefix-string exact, which is only safe because nothing that could re-point
279// a prefix ever reaches it.
280func TestCanonicalKeyRefusesShapes(t *testing.T) {
281 bad := []string{
282 "",
283 "/apps/ab12/x",
284 "apps//ab12/x",
285 "apps/ab12/../zz99/x",
286 "..",
287 "../x",
288 "apps/ab12/x/..",
289 "apps/./ab12/x",
290 "apps/ab12/x/",
291 "apps/ab12/\x00x",
292 "apps/ab12/x\ny",
293 strings.Repeat("a", 1025),
294 }
295 for _, k := range bad {
296 if got, ok := canonicalKey(k); ok {
297 t.Errorf("canonicalKey(%q) = (%q, true), want refused", k, got)
298 }
299 }
300 good := []string{
301 "control/serial",
302 "bootstrap/carlos",
303 "accounts/ab12.json",
304 "apps/ab12/web/releases/v1.2.3/app.tar.gz",
305 "fleets/pi/bindings/app.example.json",
306 strings.Repeat("a", 1024),
307 }
308 for _, k := range good {
309 got, ok := canonicalKey(k)
310 if !ok || got != k {
311 t.Errorf("canonicalKey(%q) = (%q, %v), want (%q, true)", k, got, ok, k)
312 }
313 }
314}
315
316// TestACLListScope: a listing is scope-MAPPED, never scope-checked — asking
317// for "apps/" gets you your own account's subtree, not a refusal, because
318// that is what makes an unmodified release.Store caller work through the door.
319func TestACLListScope(t *testing.T) {
320 cases := []struct {
321 prefix string
322 want string
323 ok bool
324 }{
325 {"apps/", "apps/ab12/", true},
326 {"apps", "apps/ab12/", true},
327 {"apps/ab12", "apps/ab12/", true},
328 {"apps/ab12/", "apps/ab12/", true},
329 {"apps/ab12/web/releases/", "apps/ab12/web/releases/", true},
330 {"apps/zz99/", "", false},
331 {"apps/ab1", "", false},
332 {"apps/ab12x/", "", false},
333 {"accounts/", "accounts/", true},
334 {"accounts", "accounts/", true},
335 {"accounts/zz99.json", "", false},
336 // DELIBERATE, and the trap worth naming: aclDir is STRICTER than
337 // aclRead here. aclRead admits accounts/<own-sqid>/** as an object
338 // read, but the listing scope map admits only the bare, SHARED
339 // accounts/ prefix — a caller narrowing its listing to its own
340 // account directory is refused. That is not an oversight to "fix":
341 // every real caller of this prefix lists accounts/ whole
342 // (provision.knownAccounts, domains.Reconcile, console.materialize)
343 // and takes the per-tenant filter serveListing applies, so a
344 // per-tenant mapping row here would widen the scope map for no
345 // caller. If one ever appears, adding the row is the deliberate act
346 // this pin makes visible.
347 {"accounts/" + testSqid + "/", "", false},
348 {"accounts/" + testSqid, "", false},
349 // domains/ (#151): the OTHER shared scope — every claim is
350 // cross-tenant-readable, so (unlike apps/ and fleets/) there is no
351 // per-tenant subtree to narrow into; only the bare prefix maps.
352 {"domains/", "domains/", true},
353 {"domains", "domains/", true},
354 {"domains/app.example.com.json", "", false},
355 {"fleets/", "fleets/pi/", true},
356 {"fleets/pi/status/boxes/", "fleets/pi/status/boxes/", true},
357 {"fleets/other/", "", false},
358 {"control/", "", false},
359 {"bootstrap/", "", false},
360 {"", "", false},
361 {"status/", "", false},
362 }
363 for _, c := range cases {
364 got, ok := aclListScope(testSqid, testFleet, c.prefix)
365 if got != c.want || ok != c.ok {
366 t.Errorf("aclListScope(%q) = (%q, %v), want (%q, %v)", c.prefix, got, ok, c.want, c.ok)
367 }
368 }
369}
370
371// TestACLDirIsPerTenant pins the ListDirs predicate on its own. A child
372// prefix cannot be judged by aclRead (it is a directory, not an object), and
373// judging it by "is it under the prefix we listed" would enumerate every
374// account on the platform the moment accounts/ is listed.
375func TestACLDirIsPerTenant(t *testing.T) {
376 cases := map[string]bool{
377 "accounts/" + testSqid + "/": true,
378 "accounts/zz99/": false,
379 "accounts/" + testSqid + "x/": false,
380 "accounts/": false,
381 "apps/" + testSqid + "/web/": true,
382 "apps/zz99/web/": false,
383 "apps/" + testSqid + "x/web/": false,
384 "fleets/" + testFleet + "/status/": true,
385 "fleets/other/status/": false,
386 "fleets/" + testFleet + "x/y/": false,
387 "control/": false,
388 // domains/ (#215): no longer a shared read scope; claims are flat
389 // and serveClaimListing answers dirs with nothing.
390 "domains/anything/": false,
391 }
392 for dir, want := range cases {
393 if got := aclDir(testSqid, testFleet, dir); got != want {
394 t.Errorf("aclDir(%q) = %v, want %v", dir, got, want)
395 }
396 }
397}
398
399// The promotion-record grant (ruling R17) is a read of one exact object
400// shape: no prefix under releases/ maps to a listing scope or shows as a
401// directory, the box's own account's included.
402func TestPromotionRecordsAreNotListable(t *testing.T) {
403 for _, prefix := range []string{
404 "releases/",
405 "releases",
406 "releases/" + testSqid + "/",
407 "releases/" + testSqid + "/web/",
408 "releases/" + testSqid + "/web/promoted/",
409 "releases/" + testSqid + "/web/promoted",
410 } {
411 if got, ok := aclListScope(testSqid, testFleet, prefix); ok {
412 t.Errorf("aclListScope(%q) = (%q, true), want refused", prefix, got)
413 }
414 if aclDir(testSqid, testFleet, strings.TrimSuffix(prefix, "/")+"/") {
415 t.Errorf("aclDir(%q) = true, want false", prefix)
416 }
417 }
418 key := "releases/" + testSqid + "/web/promoted/v1"
419 if aclRead("", testFleet, key) || aclRead(testSqid, "", key) {
420 t.Errorf("aclRead(%q) without both identities = true, want false", key)
421 }
422}
423
424// ---- handler tests ------------------------------------------------------
425
426// The credentials every handler test runs with: a fleet channel
427// "<fleet>/<label>" whose token hash sits in the hub's dynamic table (the
428// registry-backed source), alongside a STATIC client which must never open
429// this door.
430const (
431 testChannel = testFleet + "/box-a"
432 testToken = "box-a-bearer-token"
433 staticName = "hq"
434 staticToken = "static-client-token"
435 ownKey = "apps/" + testSqid + "/web/channels/stable.json"
436 ownFleetKey = "fleets/" + testFleet + "/status/boxes/box-a.json"
437 otherAccountKey = "apps/zz99/web/channels/stable.json"
438)
439
440// newTestDoor builds a door over a DirStore with the identities above — the
441// same wiring edge.go does in production (hub + the edge's own store handle +
442// the reload-time fleet→account snapshot).
443func newTestDoor(t *testing.T) (*storeDoor, *release.DirStore) {
444 t.Helper()
445 hub := NewTunnelHub("tunnel.test", map[string]string{staticName: staticToken})
446 hub.SetDynamicAuth(map[string]string{testChannel: sha256Hex(testToken)})
447 store := release.NewDirStore(t.TempDir())
448 return &storeDoor{hub: hub, store: store, fleetAccount: func(fleet string) (string, bool) {
449 if fleet == testFleet {
450 return testSqid, true
451 }
452 return "", false
453 }}, store
454}
455
456// doorReq builds a request with the given credentials; an empty token sends
457// no Authorization header at all, an empty name no Carlos-Tunnel-Name.
458func doorReq(method, target, name, token, body string) *http.Request {
459 var r *http.Request
460 if body == "" {
461 r = httptest.NewRequest(method, target, nil)
462 } else {
463 r = httptest.NewRequest(method, target, strings.NewReader(body))
464 }
465 if name != "" {
466 r.Header.Set("Carlos-Tunnel-Name", name)
467 }
468 if token != "" {
469 r.Header.Set("Authorization", "Bearer "+token)
470 }
471 return r
472}
473
474// doorDo runs one correctly authenticated request through the door.
475func doorDo(d *storeDoor, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder {
476 r := doorReq(method, target, testChannel, testToken, body)
477 for k, v := range hdr {
478 r.Header.Set(k, v)
479 }
480 w := httptest.NewRecorder()
481 d.ServeHTTP(w, r)
482 return w
483}
484
485func seedObject(t *testing.T, s *release.DirStore, key, body string) string {
486 t.Helper()
487 etag, err := s.Put(context.Background(), key, []byte(body), "")
488 if err != nil {
489 t.Fatalf("seed %s: %v", key, err)
490 }
491 return etag
492}
493
494func TestStoreDoorGetServesOwnEstate(t *testing.T) {
495 d, store := newTestDoor(t)
496 etag := seedObject(t, store, ownKey, `{"version":"v1"}`)
497 seedObject(t, store, "control/serial", "42")
498 seedObject(t, store, "bootstrap/carlos", "ELF...")
499 seedObject(t, store, "control/system/router/stable.json", `{"payload":"x"}`)
500 seedObject(t, store, "bootstrap/router/v1/carlos-linux-arm64", "ELF")
501
502 for _, key := range []string{ownKey, "control/serial", "bootstrap/carlos", "control/system/router/stable.json", "bootstrap/router/v1/carlos-linux-arm64"} {
503 w := doorDo(d, "GET", "/store/o/"+key, "", nil)
504 if w.Code != http.StatusOK {
505 t.Fatalf("GET %s = %d, want 200", key, w.Code)
506 }
507 }
508 w := doorDo(d, "GET", "/store/o/"+ownKey, "", nil)
509 if got := w.Body.String(); got != `{"version":"v1"}` {
510 t.Errorf("body = %q, want the stored object", got)
511 }
512 if got := w.Header().Get("ETag"); got != etag {
513 t.Errorf("ETag = %q, want the backing store's %q (etags are opaque, passed through)", got, etag)
514 }
515}
516
517// TestStoreDoorCASRoundTrip pins F4 on the wire: there is no unconditional
518// Put, a lost race is 412, and a won race hands back the new etag.
519func TestStoreDoorCASRoundTrip(t *testing.T) {
520 d, store := newTestDoor(t)
521 path := "/store/o/" + ownFleetKey
522
523 if w := doorDo(d, "PUT", path, "first", nil); w.Code != http.StatusBadRequest {
524 t.Fatalf("PUT with no precondition = %d, want 400 (the Store contract has no blind write)", w.Code)
525 }
526 if _, _, err := store.Get(context.Background(), ownFleetKey); !errors.Is(err, os.ErrNotExist) {
527 t.Fatal("a refused PUT still created the object")
528 }
529 if w := doorDo(d, "PUT", path, "x", map[string]string{"If-Match": "abc", "If-None-Match": "*"}); w.Code != http.StatusBadRequest {
530 t.Errorf("PUT with both preconditions = %d, want 400", w.Code)
531 }
532
533 w := doorDo(d, "PUT", path, "first", map[string]string{"If-None-Match": "*"})
534 if w.Code != http.StatusOK {
535 t.Fatalf("create-only PUT = %d, want 200", w.Code)
536 }
537 e1 := w.Header().Get("ETag")
538 if e1 == "" {
539 t.Fatal("create-only PUT returned no ETag")
540 }
541 if w := doorDo(d, "PUT", path, "again", map[string]string{"If-None-Match": "*"}); w.Code != http.StatusPreconditionFailed {
542 t.Errorf("create-only PUT over an existing object = %d, want 412", w.Code)
543 }
544 if w := doorDo(d, "PUT", path, "stale", map[string]string{"If-Match": "0000"}); w.Code != http.StatusPreconditionFailed {
545 t.Errorf("replace-only PUT with a stale etag = %d, want 412", w.Code)
546 }
547 w2 := doorDo(d, "PUT", path, "second", map[string]string{"If-Match": e1})
548 if w2.Code != http.StatusOK {
549 t.Fatalf("replace-only PUT with the fresh etag = %d, want 200", w2.Code)
550 }
551 if e2 := w2.Header().Get("ETag"); e2 == "" || e2 == e1 {
552 t.Errorf("ETag after replace = %q, want a new one (was %q)", e2, e1)
553 }
554 body, _, err := store.Get(context.Background(), ownFleetKey)
555 if err != nil || string(body) != "second" {
556 t.Fatalf("stored body = %q, %v; want \"second\"", body, err)
557 }
558}
559
560func TestStoreDoorDeleteIsScopedAndIdempotent(t *testing.T) {
561 d, store := newTestDoor(t)
562 seedObject(t, store, ownFleetKey, "{}")
563 seedObject(t, store, ownKey, "{}")
564
565 for i := 0; i < 2; i++ {
566 if w := doorDo(d, "DELETE", "/store/o/"+ownFleetKey, "", nil); w.Code != http.StatusNoContent {
567 t.Fatalf("DELETE own fleet key (attempt %d) = %d, want 204", i+1, w.Code)
568 }
569 }
570 if w := doorDo(d, "DELETE", "/store/o/"+ownKey, "", nil); w.Code != http.StatusNotFound {
571 t.Fatalf("DELETE a release object = %d, want 404 (retention is not a box's job)", w.Code)
572 }
573 if _, _, err := store.Get(context.Background(), ownKey); err != nil {
574 t.Fatalf("the refused DELETE removed the object anyway: %v", err)
575 }
576}
577
578// TestStoreDoorRefusalsAreByteIdentical is the spec's central security
579// invariant: bad auth, a static (non-fleet) name, another tenant's key, a
580// missing in-scope key and a malformed key must be INDISTINGUISHABLE — same
581// status, same headers, same bytes — or the door becomes a keyspace oracle.
582func TestStoreDoorRefusalsAreByteIdentical(t *testing.T) {
583 d, store := newTestDoor(t)
584 seedObject(t, store, otherAccountKey, "someone else's")
585 seedObject(t, store, "fleets/other/status/boxes/box-a.json", "another fleet's")
586 // Seeded so the account-manifest row below refuses a key that really
587 // EXISTS: the refusal must not be distinguishable from the missing-key
588 // one, which is only proved against an object that is actually there.
589 seedObject(t, store, "accounts/zz99/account.json", "another tenant's account")
590
591 req := func(name, token, method, target string) *http.Request {
592 return doorReq(method, target, name, token, "")
593 }
594 cases := map[string]*http.Request{
595 "wrong token": req(testChannel, "not-the-token", "GET", "/store/o/"+ownKey),
596 "no auth at all": req("", "", "GET", "/store/o/"+ownKey),
597 "static name + token": req(staticName, staticToken, "GET", "/store/o/control/serial"),
598 "unknown fleet": req("nosuch/box", testToken, "GET", "/store/o/control/serial"),
599 "other account's key": req(testChannel, testToken, "GET", "/store/o/"+otherAccountKey),
600 "other fleet's key": req(testChannel, testToken, "GET", "/store/o/fleets/other/status/boxes/box-a.json"),
601 "missing in-scope key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/web/nope.json"),
602 "traversal key": req(testChannel, testToken, "GET", "/store/o/apps/"+testSqid+"/../zz99/x"),
603 "empty key": req(testChannel, testToken, "GET", "/store/o/"),
604 "write out of scope": req(testChannel, testToken, "DELETE", "/store/o/"+otherAccountKey),
605 "list out of scope": req(testChannel, testToken, "GET", "/store/list?prefix=apps/zz99/"),
606 "unknown path": req(testChannel, testToken, "GET", "/store/nope"),
607 "wrong method": req(testChannel, testToken, "POST", "/store/o/"+ownKey),
608 // The two classes Task 2 deferred. The account manifest is the key a
609 // tenant-enumeration probe would reach for FIRST (accounts/<sqid>/
610 // account.json is the one object whose presence answers "does this
611 // account exist"), and /store/dirs is the whole second listing route
612 // — its refusals were never pinned to the same shape as /store/list's.
613 "other account's manifest": req(testChannel, testToken, "GET", "/store/o/accounts/zz99/account.json"),
614 "dirs out of scope": req(testChannel, testToken, "GET", "/store/dirs?prefix=accounts/zz99/"),
615 "dirs of another fleet": req(testChannel, testToken, "GET", "/store/dirs?prefix=fleets/other/"),
616 "dirs unauthenticated": req(testChannel, "not-the-token", "GET", "/store/dirs?prefix=fleets/"),
617 }
618 type shape struct {
619 code int
620 header string
621 body string
622 }
623 var want *shape
624 var wantName string
625 for name, r := range cases {
626 w := httptest.NewRecorder()
627 d.ServeHTTP(w, r)
628 got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()}
629 if got.code != http.StatusNotFound {
630 t.Errorf("%s: status = %d, want 404", name, got.code)
631 }
632 if strings.Contains(got.body, testToken) || strings.Contains(got.header, testToken) {
633 t.Errorf("%s: response echoes the bearer token", name)
634 }
635 if want == nil {
636 g := got
637 want, wantName = &g, name
638 continue
639 }
640 if got != *want {
641 t.Errorf("%q answers %+v but %q answers %+v — the two refusals are distinguishable", name, got, wantName, *want)
642 }
643 }
644}
645
646// TestDoorNeverServesCertMaterial is the permanent tripwire for the
647// multi-region ingress design's central promise (bucket-writer-audit.md):
648// control/certs/**, control/edge-mesh-key and the housekeeper's own lease
649// object are NOT in aclRead's allowlist (compare the doc comment above
650// aclRead — control/serial, bootstrap/carlos, domains/, accounts/<sqid>/,
651// apps/<sqid>/, fleets/<fleet>/, control/system/<c>/<ch>.json,
652// bootstrap/router/<v>/<asset>, and nothing else), so no fleet box can ever
653// read a private key, an ACME account key, a challenge token or the
654// housekeeper's coordination lock through this door, however it authenticates.
655//
656// It runs as an AUTHENTICATED fleet client (own sqid/fleet, real bearer
657// token) — an unauthenticated 404 would prove nothing about the ACL — across
658// all three verbs the door exposes on a single object (GET/PUT/DELETE) plus
659// both listing verbs, and it proves the refusal is byte-identical whether the
660// key exists in the backing store or not: two of the seven keys below are
661// seeded with plausible cert bytes before the loop runs, the rest are left
662// absent, and every response — seeded or not — is folded into the same
663// byte-identical comparison TestStoreDoorRefusalsAreByteIdentical uses, so an
664// existence oracle on cert material would fail exactly the same way a status
665// mismatch would.
666func TestDoorNeverServesCertMaterial(t *testing.T) {
667 d, store := newTestDoor(t)
668
669 // The real Task 4/5 key shapes (certsync.go's certKey naming: bare host,
670 // the autocert account key, an HTTP-01 token response, a tls-alpn-01
671 // token cert, a wildcard account key) plus the four other control/
672 // entries the multi-region design reserves for the housekeeper alone
673 // (bucket-writer-audit.md, "Not yet built" table): the mesh key, its
674 // rotation successor (Task 2's dual-accept — control/edge-mesh-key.next,
675 // mirrored only while a rotation is in flight, but readable through this
676 // door at every other time too and refused just as hard), and the
677 // housekeeper's own election lock.
678 certKeys := []string{
679 "control/certs/example.com",
680 "control/certs/acme_account+key",
681 "control/certs/wildcard-account.key",
682 "control/certs/tok+http-01",
683 "control/certs/example.com+token",
684 "control/edge-mesh-key",
685 "control/edge-mesh-key.next",
686 "control/housekeeper-lease/lock.json",
687 // The cert warm's outcome records (#299 §3e, internal/certwarm).
688 // They carry no key material, so the leak they would be is not the
689 // same one as the entries above — it is the EXISTENCE oracle this
690 // test's identical-shape check is really about. A door that answered
691 // differently for a record that exists would let anyone with a fleet
692 // token enumerate which of the deployment's custom domains are
693 // currently failing to get a certificate, tenant boundary and all.
694 "control/cert-warm/example.com.json",
695 }
696 // Seed three of the nine with plausible-looking secret bytes: if the ACL
697 // ever let a Get through, these are the ones that would leak real
698 // material instead of a "not found" — and mixing seeded with unseeded
699 // keys through the identical-shape check below is what proves the door
700 // isn't just refusing absent keys, it's refusing to distinguish "exists"
701 // from "doesn't" at all for this whole prefix. The mesh key and its
702 // rotation successor are seeded as a pair, same secret shape, since a
703 // leak of either is the same failure.
704 seedObject(t, store, "control/certs/example.com", "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----")
705 seedObject(t, store, "control/edge-mesh-key", "fake-mesh-shared-secret")
706 seedObject(t, store, "control/edge-mesh-key.next", "fake-mesh-shared-secret")
707
708 type shape struct {
709 code int
710 header string
711 body string
712 }
713 var want *shape
714 var wantName string
715 check := func(name string, w *httptest.ResponseRecorder) {
716 t.Helper()
717 if w.Code != http.StatusNotFound {
718 t.Errorf("%s: status = %d, want 404 — cert material must never transit the door", name, w.Code)
719 return
720 }
721 if strings.Contains(w.Body.String(), "BEGIN CERTIFICATE") || strings.Contains(w.Body.String(), "fake-mesh-shared-secret") {
722 t.Errorf("%s: response body carries seeded secret bytes", name)
723 }
724 got := shape{w.Code, fmt.Sprintf("%v", w.Header()), w.Body.String()}
725 if want == nil {
726 g := got
727 want, wantName = &g, name
728 return
729 }
730 if got != *want {
731 t.Errorf("%q answers %+v but %q answers %+v — cert refusal is distinguishable from an ordinary refusal (existence oracle)", name, got, wantName, *want)
732 }
733 }
734
735 for _, key := range certKeys {
736 check("GET "+key, doorDo(d, "GET", "/store/o/"+key, "", nil))
737 check("DELETE "+key, doorDo(d, "DELETE", "/store/o/"+key, "", nil))
738 check("PUT "+key, doorDo(d, "PUT", "/store/o/"+key, "x", map[string]string{"If-None-Match": "*"}))
739 }
740 // The listing verbs: a fleet box must never enumerate the cert prefix
741 // either, whether asking for objects (list) or child directories (dirs).
742 check("list prefix=control/certs/", doorDo(d, "GET", "/store/list?prefix=control/certs/", "", nil))
743 check("dirs prefix=control/certs/", doorDo(d, "GET", "/store/dirs?prefix=control/certs/", "", nil))
744
745 // The seeded keys must still be untouched in the backing store — a
746 // refused DELETE that quietly deleted anyway would be worse than a
747 // refused DELETE that didn't.
748 if _, _, err := store.Get(context.Background(), "control/certs/example.com"); err != nil {
749 t.Errorf("the refused DELETE removed control/certs/example.com anyway: %v", err)
750 }
751 if _, _, err := store.Get(context.Background(), "control/edge-mesh-key"); err != nil {
752 t.Errorf("the refused DELETE removed control/edge-mesh-key anyway: %v", err)
753 }
754}
755
756// TestStoreDoorListScopeStripsOtherTenants: a listing is the easiest place to
757// leak a neighbour, so every listing route is checked against a store that
758// holds another account's and another fleet's objects.
759func TestStoreDoorListScopeStripsOtherTenants(t *testing.T) {
760 d, store := newTestDoor(t)
761 seedObject(t, store, ownKey, "{}")
762 seedObject(t, store, "apps/"+testSqid+"/web/releases/v1/manifest.json", "{}")
763 seedObject(t, store, otherAccountKey, "{}")
764 seedObject(t, store, "accounts/"+testSqid+"/account.json", "{}")
765 seedObject(t, store, "accounts/zz99/account.json", "{}")
766 seedObject(t, store, ownFleetKey, "{}")
767 seedObject(t, store, "fleets/other/status/boxes/box-a.json", "{}")
768
769 list := func(target string) []string {
770 t.Helper()
771 w := doorDo(d, "GET", target, "", nil)
772 if w.Code != http.StatusOK {
773 t.Fatalf("GET %s = %d, want 200", target, w.Code)
774 }
775 var out []string
776 if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
777 t.Fatalf("GET %s: decode %q: %v", target, w.Body.String(), err)
778 }
779 return out
780 }
781 for _, c := range []struct {
782 target string
783 want []string
784 }{
785 {"/store/list?prefix=apps/", []string{ownKey, "apps/" + testSqid + "/web/releases/v1/manifest.json"}},
786 {"/store/list?prefix=accounts/", []string{"accounts/" + testSqid + "/account.json"}},
787 {"/store/list?prefix=fleets/", []string{ownFleetKey}},
788 {"/store/list?prefix=apps/" + testSqid + "/web/releases/", []string{"apps/" + testSqid + "/web/releases/v1/manifest.json"}},
789 {"/store/dirs?prefix=apps/", []string{"apps/" + testSqid + "/web/"}},
790 {"/store/dirs?prefix=fleets/", []string{"fleets/" + testFleet + "/status/"}},
791 // The regression that matters most on the dirs route: accounts/ is a
792 // SHARED prefix, and ListDirs returns one child per account on the
793 // platform. Filtering by "is it under the prefix we listed" is a
794 // tautology there — the answer must be this tenant's own directory
795 // and nothing else, with zz99 seeded above to prove it.
796 {"/store/dirs?prefix=accounts/", []string{"accounts/" + testSqid + "/"}},
797 } {
798 got := list(c.target)
799 if !slices.Equal(got, c.want) {
800 t.Errorf("%s = %v, want %v", c.target, got, c.want)
801 }
802 }
803 // An empty listing is [] on the wire, never null: the client decodes into
804 // a []string either way, but null would invite a nil-vs-empty distinction
805 // the Store contract does not have.
806 w := doorDo(d, "GET", "/store/list?prefix=apps/"+testSqid+"/nosuchapp/", "", nil)
807 if body := strings.TrimSpace(w.Body.String()); body != "[]" {
808 t.Errorf("empty listing body = %q, want []", body)
809 }
810}
811
812// doorOpCounter records how many store operations a request performed —
813// enough to prove a refused request touches the bucket zero times.
814type doorOpCounter struct {
815 release.Store
816 ops int
817}
818
819func (c *doorOpCounter) Get(ctx context.Context, key string) ([]byte, string, error) {
820 c.ops++
821 return c.Store.Get(ctx, key)
822}
823
824func (c *doorOpCounter) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
825 c.ops++
826 return c.Store.Put(ctx, key, body, ifMatch)
827}
828
829func (c *doorOpCounter) List(ctx context.Context, prefix string) ([]string, error) {
830 c.ops++
831 return c.Store.List(ctx, prefix)
832}
833
834func (c *doorOpCounter) ListDirs(ctx context.Context, prefix string) ([]string, error) {
835 c.ops++
836 return c.Store.ListDirs(ctx, prefix)
837}
838
839func (c *doorOpCounter) Delete(ctx context.Context, key string) error {
840 c.ops++
841 return c.Store.Delete(ctx, key)
842}
843
844// TestStoreDoorAuthRunsExactlyOncePerRequest pins the invariant two ways: the
845// handler contains exactly ONE call to VerifyDynamic (no path can skip it,
846// none can pay for it twice — and the auth logic itself is never
847// re-implemented here), and an unauthenticated request of any shape reaches
848// the store zero times.
849func TestStoreDoorAuthRunsExactlyOncePerRequest(t *testing.T) {
850 fset := token.NewFileSet()
851 f, err := parser.ParseFile(fset, "storedoor.go", nil, 0)
852 if err != nil {
853 t.Fatalf("parse storedoor.go: %v", err)
854 }
855 calls := 0
856 ast.Inspect(f, func(n ast.Node) bool {
857 call, ok := n.(*ast.CallExpr)
858 if !ok {
859 return true
860 }
861 if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "VerifyDynamic" {
862 calls++
863 }
864 return true
865 })
866 if calls != 1 {
867 t.Errorf("storedoor.go calls VerifyDynamic %d times, want exactly 1 (one auth, one place)", calls)
868 }
869
870 d, _ := newTestDoor(t)
871 counting := &doorOpCounter{Store: d.store}
872 d.store = counting
873 for _, r := range []*http.Request{
874 doorReq("GET", "/store/o/"+ownKey, testChannel, "wrong", ""),
875 doorReq("PUT", "/store/o/"+ownFleetKey, testChannel, "wrong", "x"),
876 doorReq("DELETE", "/store/o/"+ownFleetKey, testChannel, "wrong", ""),
877 doorReq("GET", "/store/list?prefix=apps/", testChannel, "wrong", ""),
878 doorReq("GET", "/store/dirs?prefix=apps/", testChannel, "wrong", ""),
879 doorReq("GET", "/store/o/"+ownKey, staticName, staticToken, ""),
880 doorReq("GET", "/store/o/"+ownKey, "", "", ""),
881 } {
882 w := httptest.NewRecorder()
883 d.ServeHTTP(w, r)
884 if w.Code != http.StatusNotFound {
885 t.Errorf("%s %s unauthenticated = %d, want 404", r.Method, r.URL, w.Code)
886 }
887 }
888 if counting.ops != 0 {
889 t.Errorf("unauthenticated requests performed %d store operation(s), want 0", counting.ops)
890 }
891}
892
893// TestStoreDoorOnlyOnReservedHost drives the door through the EDGE, not
894// directly: it must be unreachable on every host except the reserved tunnel
895// hostname, exactly like tunnel registration — and it must not have swallowed
896// /register on the way in.
897func TestStoreDoorOnlyOnReservedHost(t *testing.T) {
898 reg := openReg(t)
899 if err := reg.UpsertFleet(registry.Fleet{Name: testFleet, Account: testSqid, Bucket: "pi-data"}); err != nil {
900 t.Fatalf("UpsertFleet: %v", err)
901 }
902 if err := reg.AddFleetBox(registry.FleetBox{Fleet: testFleet, Label: "box-a", TokenHash: sha256Hex(testToken), Status: "active"}); err != nil {
903 t.Fatalf("AddFleetBox: %v", err)
904 }
905 backend, _ := hostEchoBackend(t, "the app, not the door")
906 if err := reg.Upsert(registry.Route{Host: "app.example", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
907 t.Fatalf("upsert route: %v", err)
908 }
909 e, err := New(reg, nil)
910 if err != nil {
911 t.Fatalf("New: %v", err)
912 }
913 store := release.NewDirStore(t.TempDir())
914 seedObject(t, store, "control/serial", "77")
915 _, pubB64, err := release.GenerateKey()
916 if err != nil {
917 t.Fatal(err)
918 }
919 pub, err := release.ParsePublicKey(pubB64, "test")
920 if err != nil {
921 t.Fatal(err)
922 }
923 e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
924 e.EnableTunnels(NewTunnelHub("tunnel.test", nil))
925 if err := e.Reload(); err != nil {
926 t.Fatalf("Reload: %v", err)
927 }
928 srv := httptest.NewServer(e)
929 defer srv.Close()
930
931 get := func(host, path string) (int, string) {
932 t.Helper()
933 req, err := http.NewRequest("GET", srv.URL+path, nil)
934 if err != nil {
935 t.Fatal(err)
936 }
937 req.Host = host
938 req.Header.Set("Carlos-Tunnel-Name", testChannel)
939 req.Header.Set("Authorization", "Bearer "+testToken)
940 resp, err := http.DefaultClient.Do(req)
941 if err != nil {
942 t.Fatalf("GET %s (host %s): %v", path, host, err)
943 }
944 defer resp.Body.Close()
945 b, _ := io.ReadAll(resp.Body)
946 return resp.StatusCode, string(b)
947 }
948 if code, body := get("tunnel.test", "/store/o/control/serial"); code != http.StatusOK || body != "77" {
949 t.Fatalf("on the reserved host: %d %q, want 200 \"77\"", code, body)
950 }
951 if code, body := get("app.example", "/store/o/control/serial"); code != http.StatusOK || body != "the app, not the door" {
952 t.Fatalf("on a routed host: %d %q — the door answered on a host it must not serve", code, body)
953 }
954 if code, _ := get("nosuch.example", "/store/o/control/serial"); code == http.StatusOK {
955 t.Fatal("the door answered on an unknown host")
956 }
957 // /register stays the hub's on the same hostname: no upgrade headers, so
958 // a 400 (the hub's wire-contract refusal), never the door's 404.
959 if code, _ := get("tunnel.test", "/register"); code != http.StatusBadRequest {
960 t.Fatalf("/register on the reserved host = %d, want 400 (still the hub's)", code)
961 }
962}
963
964// TestStoreDoorClaimsAreOwnAccountAndRedacted (#215): a fleet box used to be
965// able to read every tenant's hostname claims — who made each one (a
966// person's address), its account and app, its path map, and whether anyone
967// had proved it. It now reads only its own account's verified claims,
968// without created_by; every other claim body answers the door's
969// byte-identical 404. The listing still names every claimed hostname.
970func TestStoreDoorClaimsAreOwnAccountAndRedacted(t *testing.T) {
971 d, store := newTestDoor(t)
972 ctx := context.Background()
973 put := func(c domains.Claim) {
974 t.Helper()
975 if _, _, _, err := domains.AttachClaimProven(ctx, store, c, time.Now(), func() bool { return !c.Pending }); err != nil {
976 t.Fatal(err)
977 }
978 }
979 put(domains.Claim{Host: "own.example.com", Account: testSqid, App: "web", Route: "web." + testSqid + ".oncarlos.com", CreatedBy: "paul@keymail.dev"})
980 put(domains.Claim{Host: "pending.example.com", Account: testSqid, App: "web", Pending: true})
981 put(domains.Claim{Host: "theirs.example.com", Account: "zz99", App: "shop", CreatedBy: "sam@keymail.dev"})
982
983 w := doorDo(d, "GET", "/store/list?prefix=domains/", "", nil)
984 // Every claimed NAME is listed, whoever holds it: the alias pass decides
985 // "someone else claimed this" from the listing. Bodies are what is
986 // withheld (below).
987 if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) !=
988 `["domains/own.example.com.json","domains/pending.example.com.json","domains/theirs.example.com.json"]` {
989 t.Fatalf("listing = %d %s, want every claimed hostname", w.Code, w.Body.String())
990 }
991 w = doorDo(d, "GET", "/store/o/domains/own.example.com.json", "", nil)
992 if w.Code != http.StatusOK {
993 t.Fatalf("own claim = %d", w.Code)
994 }
995 var c domains.Claim
996 if err := json.Unmarshal(w.Body.Bytes(), &c); err != nil {
997 t.Fatal(err)
998 }
999 if c.Host != "own.example.com" || c.Route == "" || c.CreatedBy != "" || strings.Contains(w.Body.String(), "keymail.dev") {
1000 t.Fatalf("own claim body = %s, want the claim without created_by", w.Body.String())
1001 }
1002 want := doorDo(d, "GET", "/store/o/domains/absent.example.com.json", "", nil)
1003 for _, key := range []string{"theirs.example.com", "pending.example.com"} {
1004 got := doorDo(d, "GET", "/store/o/domains/"+key+".json", "", nil)
1005 if got.Code != http.StatusNotFound || got.Body.String() != want.Body.String() || fmt.Sprint(got.Header()) != fmt.Sprint(want.Header()) {
1006 t.Errorf("%s = %d %q, want the same 404 an absent claim gets", key, got.Code, got.Body.String())
1007 }
1008 }
1009 if w := doorDo(d, "GET", "/store/dirs?prefix=domains/", "", nil); strings.TrimSpace(w.Body.String()) != "[]" {
1010 t.Errorf("dirs under domains/ = %s, want []", w.Body.String())
1011 }
1012}
1013
1014// TestFleetBoxCanReadThePlansMarker: a fleet box reads the plans marker
1015// (plans 2) to learn whether to read limits at all; the same two-identity
1016// guard applies.
1017func TestFleetBoxCanReadThePlansMarker(t *testing.T) {
1018 key := plans.MarkerKey()
1019 if !aclRead(testSqid, testFleet, key) {
1020 t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key)
1021 }
1022 if aclRead("", testFleet, key) || aclRead(testSqid, "", key) {
1023 t.Error("the plans marker was readable without both identities")
1024 }
1025}
1026