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
3import (
4 "context"
5 "net/http"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/objstore"
11 "github.com/carlosframework/platform/internal/registry"
12 "github.com/carlosframework/platform/internal/release"
13 "github.com/carlosframework/platform/internal/svcrole"
14)
15
16func enqueueURL(fx *consoleFixture, app string) string {
17 return scanURL(fx, app) + "/object"
18}
19
20// appToken is the credential this door is FOR: an app's own service principal,
21// bound to one app, holding the Service role and nothing else.
22func appToken(t *testing.T, fx *consoleFixture, name string) string {
23 t.Helper()
24 return serviceToken(t, fx.st, OpsAccountID, name, "titogo", svcrole.Service)
25}
26
27// queuedKeys is what actually landed in the bucket. The door's 202 is a
28// promise about an object, so every test that reads a 202 checks the object,
29// and every test that reads a refusal checks that there is none — a refusal
30// that still enqueued would be the worst of both.
31func queuedKeys(t *testing.T, fx *consoleFixture, app string) []string {
32 t.Helper()
33 keys, err := fx.rel.List(context.Background(), objstore.QueuePrefix(release.OpsAccount, app))
34 if err != nil {
35 t.Fatalf("list queue: %v", err)
36 }
37 return keys
38}
39
40// enqueueArmed is the whole consent chain satisfied and an engine wired: the
41// only state in which this door says 202.
42func enqueueArmed(t *testing.T, code string) (*consoleFixture, string) {
43 t.Helper()
44 fx, _, token, _ := storeArmed(t, code)
45 grantStore(t, fx, token)
46 requestScanning(t, fx, token, false)
47 // Engine, then the deployment switch, THEN the per-store grant. That is
48 // the real sequence and the order is not incidental: `scan grant` refuses
49 // with no engine wired (there is nothing to enable) and refuses again
50 // while the deployment switch is off (it would be enabling a control that
51 // is not running).
52 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
53 fx.srv.cfg.AppsDomain = "oncarlos.test"
54 fx.addRoute(t, registry.Route{Host: "custom.example.test", Kind: "instance", Status: "ready", App: "titogo", Account: release.OpsAccount})
55 fx.addRoute(t, registry.Route{Host: "titogo." + release.OpsAccount + ".oncarlos.test", Kind: "instance", Status: "ready", App: "titogo", Account: release.OpsAccount, Platform: true})
56 enableDeploymentScanning(t, fx)
57 fx.srv.cfg.StoreOperators = []string{"alice@keymail.dev"}
58 if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK {
59 t.Fatalf("scan grant = %d %v", s, o)
60 }
61 return fx, appToken(t, fx, "titobot")
62}
63
64// TestEnqueueAcceptsAndWritesTheItem is the positive control every refusal
65// test below is measured against. Without it a door that refused EVERYTHING
66// would pass the entire rest of this file.
67func TestEnqueueAcceptsAndWritesTheItem(t *testing.T) {
68 fx, tok := enqueueArmed(t, "code-enq-ok")
69
70 status, out := postJSON(t, enqueueURL(fx, "titogo"),
71 map[string]any{"key": "uploads/x.pdf", "callback_path": "/hooks/scan"}, tok)
72 if status != http.StatusAccepted {
73 t.Fatalf("enqueue = %d %v, want 202", status, out)
74 }
75 if out["state"] != "pending" {
76 t.Errorf("state = %v, want pending", out["state"])
77 }
78 // No id in the reply: the status door is keyed by object key, so an id
79 // here would be a handle with nothing to hand it to.
80 if _, ok := out["id"]; ok {
81 t.Errorf("reply carries an id (%v) — nothing consumes one, and a client would build on it", out)
82 }
83 keys := queuedKeys(t, fx, "titogo")
84 if len(keys) != 1 {
85 t.Fatalf("queued %d items, want 1: %v", len(keys), keys)
86 }
87 // And the index knows, or the drain never looks at this app.
88 if n := fx.srv.scanQueue.Len(); n != 1 {
89 t.Errorf("index holds %d apps, want 1 — an item nothing points at is stranded until the next boot", n)
90 }
91}
92
93// TestEnqueueRefusesEveryConsentState is §5.1.1's chain, one case per name.
94//
95// The states are built by STOPPING the real sequence at each step rather than
96// by writing records by hand: a fixture that hand-rolls "granted but not
97// requested" can drift from what the grant door actually writes, and then the
98// test passes against a shape the deployment never produces.
99func TestEnqueueRefusesEveryConsentState(t *testing.T) {
100 for _, tc := range []struct {
101 name string
102 reason string
103 status int
104 // arm builds the state and returns the app's token.
105 arm func(t *testing.T, fx *consoleFixture, member string) string
106 }{{
107 name: "no store at all",
108 reason: "not_granted",
109 status: http.StatusConflict,
110 arm: func(t *testing.T, fx *consoleFixture, member string) string {
111 return appToken(t, fx, "bot")
112 },
113 }, {
114 name: "store granted, scanning never requested",
115 reason: "not_requested",
116 status: http.StatusConflict,
117 arm: func(t *testing.T, fx *consoleFixture, member string) string {
118 grantStore(t, fx, member)
119 return appToken(t, fx, "bot")
120 },
121 }, {
122 name: "requested, but attested client-encrypted",
123 reason: "encrypted",
124 status: http.StatusConflict,
125 arm: func(t *testing.T, fx *consoleFixture, member string) string {
126 grantStore(t, fx, member)
127 requestScanning(t, fx, member, true)
128 return appToken(t, fx, "bot")
129 },
130 }, {
131 name: "requested, but no operator grant",
132 reason: "not_enabled",
133 status: http.StatusConflict,
134 arm: func(t *testing.T, fx *consoleFixture, member string) string {
135 grantStore(t, fx, member)
136 requestScanning(t, fx, member, false)
137 return appToken(t, fx, "bot")
138 },
139 }, {
140 name: "everything granted, deployment switched off",
141 reason: "disabled",
142 status: http.StatusServiceUnavailable,
143 arm: func(t *testing.T, fx *consoleFixture, member string) string {
144 grantStore(t, fx, member)
145 requestScanning(t, fx, member, false)
146 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
147 enableDeploymentScanning(t, fx)
148 fx.srv.cfg.StoreOperators = []string{"alice@keymail.dev"}
149 if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, member); s != http.StatusOK {
150 t.Fatalf("scan grant = %d %v", s, o)
151 }
152 // Granted, and THEN switched off: the one refusal that is
153 // reversible, and the only one where the store itself is ready.
154 disableDeploymentScanning(t, fx)
155 return appToken(t, fx, "bot")
156 },
157 }} {
158 t.Run(tc.name, func(t *testing.T) {
159 fx, _, member, _ := storeArmed(t, "code-enq-"+strings.ReplaceAll(tc.name, " ", "-"))
160 tok := tc.arm(t, fx, member)
161
162 status, out := postJSON(t, enqueueURL(fx, "titogo"),
163 map[string]any{"key": "uploads/x.pdf"}, tok)
164 if status != tc.status {
165 t.Fatalf("enqueue = %d %v, want %d", status, out, tc.status)
166 }
167 if out["state"] != "unavailable" {
168 t.Errorf("state = %v, want unavailable", out["state"])
169 }
170 if out["reason"] != tc.reason {
171 t.Errorf("reason = %v, want %q", out["reason"], tc.reason)
172 }
173 // A reason with no sentence beside it leaves whoever reads the
174 // log with a token and no next step.
175 if msg, _ := out["error"].(string); strings.TrimSpace(msg) == "" {
176 t.Errorf("no error sentence beside reason %q: %v", tc.reason, out)
177 }
178 if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
179 t.Errorf("refused with %q and still queued %v", tc.reason, keys)
180 }
181 })
182 }
183}
184
185// TestEnqueueRefusesWithoutAttestation is the hole review 3 found, on its own
186// rather than as one row of the table above, because it is the one a future
187// change is most likely to reintroduce.
188//
189// Everything the DEPLOYMENT controls is in place: an engine is wired, the
190// switch is on, the store is granted and provisioned. The only thing missing
191// is the member's own `carlos store scan request` — the attestation that says
192// whether these bytes are readable at all. A door that checked the deployment
193// switch and stopped would go straight through, and the console would fetch
194// and scan a tenant's objects on the say-so of that tenant's own app.
195func TestEnqueueRefusesWithoutAttestation(t *testing.T) {
196 fx, _, member, _ := storeArmed(t, "code-enq-attest")
197 grantStore(t, fx, member)
198 fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
199 enableDeploymentScanning(t, fx)
200 tok := appToken(t, fx, "titobot")
201
202 status, out := postJSON(t, enqueueURL(fx, "titogo"),
203 map[string]any{"key": "uploads/x.pdf"}, tok)
204 if status != http.StatusConflict || out["reason"] != "not_requested" {
205 t.Fatalf("enqueue = %d %v, want 409 not_requested — the sweep will not read these bytes either", status, out)
206 }
207 if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
208 t.Fatalf("queued %v without the member's attestation", keys)
209 }
210}
211
212// TestEnqueueRequiresServiceRole pins the role, both ways.
213//
214// Admin reaching it is stated rather than fought: svcrole.Admin is the union
215// of every role by construction, so a test asserting Admin is refused here
216// would be asserting the opposite of how the role set works, and the next
217// person would "fix" the role table to satisfy it.
218func TestEnqueueRequiresServiceRole(t *testing.T) {
219 fx, _ := enqueueArmed(t, "code-enq-role")
220
221 for _, tc := range []struct {
222 role svcrole.Role
223 want int
224 }{
225 {svcrole.Publish, http.StatusForbidden},
226 {svcrole.Instance, http.StatusForbidden},
227 {svcrole.Operate, http.StatusForbidden},
228 {svcrole.Service, http.StatusAccepted},
229 {svcrole.Admin, http.StatusAccepted},
230 } {
231 t.Run(string(tc.role), func(t *testing.T) {
232 other := serviceToken(t, fx.st, OpsAccountID, "bot-"+string(tc.role), "titogo", tc.role)
233 status, out := postJSON(t, enqueueURL(fx, "titogo"),
234 map[string]any{"key": "uploads/" + string(tc.role) + ".pdf"}, other)
235 if status != tc.want {
236 t.Fatalf("role %s = %d %v, want %d", tc.role, status, out, tc.want)
237 }
238 })
239 }
240}
241
242// TestEnqueueRefusesAHostileCallbackPath proves the DOOR calls the validator.
243//
244// scanhook_test.go proves the validator is right about each shape; that is a
245// different claim from "nothing hostile can be stored", which is only true if
246// the door runs it on the way in. One row per family is enough here — the
247// exhaustive table belongs where the rule lives — and what this asserts on
248// top of the 400 is that the bucket stayed empty.
249func TestEnqueueRefusesAHostileCallbackPath(t *testing.T) {
250 fx, tok := enqueueArmed(t, "code-enq-path")
251
252 for _, p := range []string{
253 "https://evil.example/x", // absolute
254 "//evil.example/x", // protocol-relative
255 "/\\evil.example/x", // backslash, read as protocol-relative by enough parsers
256 "/hooks/../../admin", // traversal
257 "/hooks\r\nX-Evil: 1", // header injection
258 "/hooks%0d%0aX-Evil:1", // and its encoded form
259 "/hooks#frag", // fragment
260 "/x@evil.example/y", // smuggled authority
261 "/" + strings.Repeat("a", maxCallbackPath), // over the cap
262 } {
263 t.Run(p, func(t *testing.T) {
264 status, out := postJSON(t, enqueueURL(fx, "titogo"),
265 map[string]any{"key": "uploads/x.pdf", "callback_path": p}, tok)
266 if status != http.StatusBadRequest {
267 t.Fatalf("callback_path %q = %d %v, want 400", p, status, out)
268 }
269 if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
270 t.Fatalf("stored an item carrying %q: %v", p, keys)
271 }
272 })
273 }
274}
275
276// TestEnqueueRefusesABodyItHalfUnderstands is why this door decodes strictly.
277//
278// An app that misspells callback_path would otherwise be answered 202 for an
279// item carrying no callback, and would wait forever for a delivery nobody ever
280// promised. The 400 names the field, which is the whole of the fix.
281func TestEnqueueRefusesABodyItHalfUnderstands(t *testing.T) {
282 fx, tok := enqueueArmed(t, "code-enq-strict")
283
284 status, out := postJSON(t, enqueueURL(fx, "titogo"),
285 map[string]any{"key": "uploads/x.pdf", "callback_url": "/hooks/scan"}, tok)
286 if status != http.StatusBadRequest {
287 t.Fatalf("unknown field = %d %v, want 400", status, out)
288 }
289 if msg, _ := out["error"].(string); !strings.Contains(msg, "callback_url") {
290 t.Errorf("error %q does not name the field the caller got wrong", msg)
291 }
292 if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
293 t.Fatalf("queued %v from a body it did not fully understand", keys)
294 }
295}
296
297// TestEnqueueNeedsAKey: a 202 is a promise about an object, and there is no
298// object here.
299func TestEnqueueNeedsAKey(t *testing.T) {
300 fx, tok := enqueueArmed(t, "code-enq-nokey")
301
302 for _, key := range []string{"", " "} {
303 status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": key}, tok)
304 if status != http.StatusBadRequest {
305 t.Fatalf("key %q = %d %v, want 400", key, status, out)
306 }
307 }
308 if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
309 t.Fatalf("queued %v with no key", keys)
310 }
311}
312
313// TestEnqueueRefusesWithNoEngineWired is the 503 that keeps the 202 honest:
314// with no engine the drain can never run, so an accepted item would sit until
315// its TTL and the app would wait on an answer that was never coming.
316//
317// It is checked LAST, after the consent chain, so an app learns why ITS store
318// cannot be scanned before it learns anything about the deployment.
319func TestEnqueueRefusesWithNoEngineWired(t *testing.T) {
320 fx, tok := enqueueArmed(t, "code-enq-noengine")
321 fx.srv.cfg.StoreScanner = nil
322
323 status, out := postJSON(t, enqueueURL(fx, "titogo"),
324 map[string]any{"key": "uploads/x.pdf"}, tok)
325 if status != http.StatusServiceUnavailable {
326 t.Fatalf("enqueue with no engine = %d %v, want 503", status, out)
327 }
328 if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 {
329 t.Fatalf("queued %v with no engine to drain it", keys)
330 }
331}
332
333// TestEnqueueIsRateLimited: the door writes an object per call, so an app in a
334// loop is an app filling its own bucket. 429 is the same limiter every other
335// write door on this console shares.
336func TestEnqueueIsRateLimited(t *testing.T) {
337 fx, tok := enqueueArmed(t, "code-enq-429")
338
339 saw429 := false
340 for i := 0; i < 200; i++ {
341 status, _ := postRaw(t, enqueueURL(fx, "titogo"),
342 map[string]any{"key": "uploads/x.pdf"}, tok)
343 if status == http.StatusTooManyRequests {
344 saw429 = true
345 break
346 }
347 if status != http.StatusAccepted {
348 t.Fatalf("enqueue #%d = %d, want 202 until the limiter bites", i, status)
349 }
350 }
351 if !saw429 {
352 t.Fatal("200 enqueues and no 429 — this door writes an object per call")
353 }
354}
355
356// TestEnqueueIsScopedToItsOwnApp: an app-bound service credential reaches
357// exactly one app, and the refusal is the family's 404 rather than a 403 that
358// would confirm the other app exists.
359func TestEnqueueIsScopedToItsOwnApp(t *testing.T) {
360 fx, _ := enqueueArmed(t, "code-enq-scope")
361 if _, err := fx.st.CreateAppIn(OpsAccountID, "otherapp", "paul@keymail.dev", time.Now()); err != nil {
362 t.Fatal(err)
363 }
364 tok := appToken(t, fx, "scopedbot")
365
366 status, _ := postRaw(t, enqueueURL(fx, "otherapp"), map[string]any{"key": "uploads/x.pdf"}, tok)
367 if status != http.StatusNotFound {
368 t.Fatalf("cross-app enqueue = %d, want 404", status)
369 }
370}
371
372// TestScanRefusalAnswerCoversEveryName is the table's own test, and it exists
373// because two of its arms cannot be reached through the door.
374//
375// A trashed app is a 404 at apiAccountApp before the consent chain is
376// consulted, and a refusal name objstore has not defined yet does not exist to
377// send. Both were mutation-tested through HTTP and both survived — not because
378// the code was wrong, but because no request can distinguish them. So they are
379// asserted where they ARE observable.
380//
381// The list is written out rather than derived: Go cannot enumerate the
382// constants of a string type, so a derived list would be this list anyway,
383// with the compiler no longer checking that each name still exists.
384func TestScanRefusalAnswerCoversEveryName(t *testing.T) {
385 named := []objstore.QueueRefusal{
386 objstore.RefusalNotGranted,
387 objstore.RefusalTrashed,
388 objstore.RefusalNotRequested,
389 objstore.RefusalEncrypted,
390 objstore.RefusalNotEnabled,
391 objstore.RefusalDisabled,
392 }
393 for _, r := range named {
394 ans := scanRefusalAnswer(r)
395 if !ans.refused() {
396 t.Errorf("%q: the door does not refuse it — a consent refusal that enqueues is the hole this chain exists to close", r)
397 }
398 if ans.unknown {
399 t.Errorf("%q: answered as an unrecognised name, so the door has not been taught it", r)
400 }
401 if ans.notFound {
402 continue // the 404 arm carries no sentence, by design
403 }
404 if ans.status < 400 {
405 t.Errorf("%q: status %d is not a refusal", r, ans.status)
406 }
407 if strings.TrimSpace(ans.msg) == "" {
408 t.Errorf("%q: refused with no sentence beside the reason", r)
409 }
410 }
411
412 // The trashed arm answers as the family's 404 and says nothing else. A
413 // reason here would make this door the one that confirms an app exists.
414 if ans := scanRefusalAnswer(objstore.RefusalTrashed); !ans.notFound || ans.status != 0 {
415 t.Errorf("trashed = %+v, want the bare 404 apiAccountApp already gives", ans)
416 }
417
418 // RefusalNone is the ONLY value that means yes.
419 if ans := scanRefusalAnswer(objstore.RefusalNone); ans.refused() {
420 t.Errorf("RefusalNone = %+v, want no refusal at all", ans)
421 }
422
423 // A name objstore might add tomorrow. The door has never seen it, and the
424 // answer must still be a refusal: costing a working feature is survivable,
425 // letting an unscanned object through on an unrecognised refusal is not.
426 ans := scanRefusalAnswer(objstore.QueueRefusal("some_future_reason"))
427 if !ans.refused() {
428 t.Fatal("an unrecognised refusal enqueues — a name added to objstore would silently open this door")
429 }
430 if !ans.unknown {
431 t.Error("an unrecognised refusal is not flagged unknown, so nothing would be logged about it")
432 }
433}
434