package console import ( "context" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" ) func enqueueURL(fx *consoleFixture, app string) string { return scanURL(fx, app) + "/object" } // appToken is the credential this door is FOR: an app's own service principal, // bound to one app, holding the Service role and nothing else. func appToken(t *testing.T, fx *consoleFixture, name string) string { t.Helper() return serviceToken(t, fx.st, OpsAccountID, name, "titogo", svcrole.Service) } // queuedKeys is what actually landed in the bucket. The door's 202 is a // promise about an object, so every test that reads a 202 checks the object, // and every test that reads a refusal checks that there is none — a refusal // that still enqueued would be the worst of both. func queuedKeys(t *testing.T, fx *consoleFixture, app string) []string { t.Helper() keys, err := fx.rel.List(context.Background(), objstore.QueuePrefix(release.OpsAccount, app)) if err != nil { t.Fatalf("list queue: %v", err) } return keys } // enqueueArmed is the whole consent chain satisfied and an engine wired: the // only state in which this door says 202. func enqueueArmed(t *testing.T, code string) (*consoleFixture, string) { t.Helper() fx, _, token, _ := storeArmed(t, code) grantStore(t, fx, token) requestScanning(t, fx, token, false) // Engine, then the deployment switch, THEN the per-store grant. That is // the real sequence and the order is not incidental: `scan grant` refuses // with no engine wired (there is nothing to enable) and refuses again // while the deployment switch is off (it would be enabling a control that // is not running). fx.srv.cfg.StoreScanner = objstore.NewMemScanner() fx.srv.cfg.AppsDomain = "oncarlos.test" fx.addRoute(t, registry.Route{Host: "custom.example.test", Kind: "instance", Status: "ready", App: "titogo", Account: release.OpsAccount}) fx.addRoute(t, registry.Route{Host: "titogo." + release.OpsAccount + ".oncarlos.test", Kind: "instance", Status: "ready", App: "titogo", Account: release.OpsAccount, Platform: true}) enableDeploymentScanning(t, fx) fx.srv.cfg.StoreOperators = []string{"alice@keymail.dev"} if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { t.Fatalf("scan grant = %d %v", s, o) } return fx, appToken(t, fx, "titobot") } // TestEnqueueAcceptsAndWritesTheItem is the positive control every refusal // test below is measured against. Without it a door that refused EVERYTHING // would pass the entire rest of this file. func TestEnqueueAcceptsAndWritesTheItem(t *testing.T) { fx, tok := enqueueArmed(t, "code-enq-ok") status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf", "callback_path": "/hooks/scan"}, tok) if status != http.StatusAccepted { t.Fatalf("enqueue = %d %v, want 202", status, out) } if out["state"] != "pending" { t.Errorf("state = %v, want pending", out["state"]) } // No id in the reply: the status door is keyed by object key, so an id // here would be a handle with nothing to hand it to. if _, ok := out["id"]; ok { t.Errorf("reply carries an id (%v) — nothing consumes one, and a client would build on it", out) } keys := queuedKeys(t, fx, "titogo") if len(keys) != 1 { t.Fatalf("queued %d items, want 1: %v", len(keys), keys) } // And the index knows, or the drain never looks at this app. if n := fx.srv.scanQueue.Len(); n != 1 { t.Errorf("index holds %d apps, want 1 — an item nothing points at is stranded until the next boot", n) } } // TestEnqueueRefusesEveryConsentState is §5.1.1's chain, one case per name. // // The states are built by STOPPING the real sequence at each step rather than // by writing records by hand: a fixture that hand-rolls "granted but not // requested" can drift from what the grant door actually writes, and then the // test passes against a shape the deployment never produces. func TestEnqueueRefusesEveryConsentState(t *testing.T) { for _, tc := range []struct { name string reason string status int // arm builds the state and returns the app's token. arm func(t *testing.T, fx *consoleFixture, member string) string }{{ name: "no store at all", reason: "not_granted", status: http.StatusConflict, arm: func(t *testing.T, fx *consoleFixture, member string) string { return appToken(t, fx, "bot") }, }, { name: "store granted, scanning never requested", reason: "not_requested", status: http.StatusConflict, arm: func(t *testing.T, fx *consoleFixture, member string) string { grantStore(t, fx, member) return appToken(t, fx, "bot") }, }, { name: "requested, but attested client-encrypted", reason: "encrypted", status: http.StatusConflict, arm: func(t *testing.T, fx *consoleFixture, member string) string { grantStore(t, fx, member) requestScanning(t, fx, member, true) return appToken(t, fx, "bot") }, }, { name: "requested, but no operator grant", reason: "not_enabled", status: http.StatusConflict, arm: func(t *testing.T, fx *consoleFixture, member string) string { grantStore(t, fx, member) requestScanning(t, fx, member, false) return appToken(t, fx, "bot") }, }, { name: "everything granted, deployment switched off", reason: "disabled", status: http.StatusServiceUnavailable, arm: func(t *testing.T, fx *consoleFixture, member string) string { grantStore(t, fx, member) requestScanning(t, fx, member, false) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) fx.srv.cfg.StoreOperators = []string{"alice@keymail.dev"} if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, member); s != http.StatusOK { t.Fatalf("scan grant = %d %v", s, o) } // Granted, and THEN switched off: the one refusal that is // reversible, and the only one where the store itself is ready. disableDeploymentScanning(t, fx) return appToken(t, fx, "bot") }, }} { t.Run(tc.name, func(t *testing.T) { fx, _, member, _ := storeArmed(t, "code-enq-"+strings.ReplaceAll(tc.name, " ", "-")) tok := tc.arm(t, fx, member) status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf"}, tok) if status != tc.status { t.Fatalf("enqueue = %d %v, want %d", status, out, tc.status) } if out["state"] != "unavailable" { t.Errorf("state = %v, want unavailable", out["state"]) } if out["reason"] != tc.reason { t.Errorf("reason = %v, want %q", out["reason"], tc.reason) } // A reason with no sentence beside it leaves whoever reads the // log with a token and no next step. if msg, _ := out["error"].(string); strings.TrimSpace(msg) == "" { t.Errorf("no error sentence beside reason %q: %v", tc.reason, out) } if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 { t.Errorf("refused with %q and still queued %v", tc.reason, keys) } }) } } // TestEnqueueRefusesWithoutAttestation is the hole review 3 found, on its own // rather than as one row of the table above, because it is the one a future // change is most likely to reintroduce. // // Everything the DEPLOYMENT controls is in place: an engine is wired, the // switch is on, the store is granted and provisioned. The only thing missing // is the member's own `carlos store scan request` — the attestation that says // whether these bytes are readable at all. A door that checked the deployment // switch and stopped would go straight through, and the console would fetch // and scan a tenant's objects on the say-so of that tenant's own app. func TestEnqueueRefusesWithoutAttestation(t *testing.T) { fx, _, member, _ := storeArmed(t, "code-enq-attest") grantStore(t, fx, member) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) tok := appToken(t, fx, "titobot") status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf"}, tok) if status != http.StatusConflict || out["reason"] != "not_requested" { t.Fatalf("enqueue = %d %v, want 409 not_requested — the sweep will not read these bytes either", status, out) } if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 { t.Fatalf("queued %v without the member's attestation", keys) } } // TestEnqueueRequiresServiceRole pins the role, both ways. // // Admin reaching it is stated rather than fought: svcrole.Admin is the union // of every role by construction, so a test asserting Admin is refused here // would be asserting the opposite of how the role set works, and the next // person would "fix" the role table to satisfy it. func TestEnqueueRequiresServiceRole(t *testing.T) { fx, _ := enqueueArmed(t, "code-enq-role") for _, tc := range []struct { role svcrole.Role want int }{ {svcrole.Publish, http.StatusForbidden}, {svcrole.Instance, http.StatusForbidden}, {svcrole.Operate, http.StatusForbidden}, {svcrole.Service, http.StatusAccepted}, {svcrole.Admin, http.StatusAccepted}, } { t.Run(string(tc.role), func(t *testing.T) { other := serviceToken(t, fx.st, OpsAccountID, "bot-"+string(tc.role), "titogo", tc.role) status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/" + string(tc.role) + ".pdf"}, other) if status != tc.want { t.Fatalf("role %s = %d %v, want %d", tc.role, status, out, tc.want) } }) } } // TestEnqueueRefusesAHostileCallbackPath proves the DOOR calls the validator. // // scanhook_test.go proves the validator is right about each shape; that is a // different claim from "nothing hostile can be stored", which is only true if // the door runs it on the way in. One row per family is enough here — the // exhaustive table belongs where the rule lives — and what this asserts on // top of the 400 is that the bucket stayed empty. func TestEnqueueRefusesAHostileCallbackPath(t *testing.T) { fx, tok := enqueueArmed(t, "code-enq-path") for _, p := range []string{ "https://evil.example/x", // absolute "//evil.example/x", // protocol-relative "/\\evil.example/x", // backslash, read as protocol-relative by enough parsers "/hooks/../../admin", // traversal "/hooks\r\nX-Evil: 1", // header injection "/hooks%0d%0aX-Evil:1", // and its encoded form "/hooks#frag", // fragment "/x@evil.example/y", // smuggled authority "/" + strings.Repeat("a", maxCallbackPath), // over the cap } { t.Run(p, func(t *testing.T) { status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf", "callback_path": p}, tok) if status != http.StatusBadRequest { t.Fatalf("callback_path %q = %d %v, want 400", p, status, out) } if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 { t.Fatalf("stored an item carrying %q: %v", p, keys) } }) } } // TestEnqueueRefusesABodyItHalfUnderstands is why this door decodes strictly. // // An app that misspells callback_path would otherwise be answered 202 for an // item carrying no callback, and would wait forever for a delivery nobody ever // promised. The 400 names the field, which is the whole of the fix. func TestEnqueueRefusesABodyItHalfUnderstands(t *testing.T) { fx, tok := enqueueArmed(t, "code-enq-strict") status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf", "callback_url": "/hooks/scan"}, tok) if status != http.StatusBadRequest { t.Fatalf("unknown field = %d %v, want 400", status, out) } if msg, _ := out["error"].(string); !strings.Contains(msg, "callback_url") { t.Errorf("error %q does not name the field the caller got wrong", msg) } if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 { t.Fatalf("queued %v from a body it did not fully understand", keys) } } // TestEnqueueNeedsAKey: a 202 is a promise about an object, and there is no // object here. func TestEnqueueNeedsAKey(t *testing.T) { fx, tok := enqueueArmed(t, "code-enq-nokey") for _, key := range []string{"", " "} { status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": key}, tok) if status != http.StatusBadRequest { t.Fatalf("key %q = %d %v, want 400", key, status, out) } } if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 { t.Fatalf("queued %v with no key", keys) } } // TestEnqueueRefusesWithNoEngineWired is the 503 that keeps the 202 honest: // with no engine the drain can never run, so an accepted item would sit until // its TTL and the app would wait on an answer that was never coming. // // It is checked LAST, after the consent chain, so an app learns why ITS store // cannot be scanned before it learns anything about the deployment. func TestEnqueueRefusesWithNoEngineWired(t *testing.T) { fx, tok := enqueueArmed(t, "code-enq-noengine") fx.srv.cfg.StoreScanner = nil status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf"}, tok) if status != http.StatusServiceUnavailable { t.Fatalf("enqueue with no engine = %d %v, want 503", status, out) } if keys := queuedKeys(t, fx, "titogo"); len(keys) != 0 { t.Fatalf("queued %v with no engine to drain it", keys) } } // TestEnqueueIsRateLimited: the door writes an object per call, so an app in a // loop is an app filling its own bucket. 429 is the same limiter every other // write door on this console shares. func TestEnqueueIsRateLimited(t *testing.T) { fx, tok := enqueueArmed(t, "code-enq-429") saw429 := false for i := 0; i < 200; i++ { status, _ := postRaw(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x.pdf"}, tok) if status == http.StatusTooManyRequests { saw429 = true break } if status != http.StatusAccepted { t.Fatalf("enqueue #%d = %d, want 202 until the limiter bites", i, status) } } if !saw429 { t.Fatal("200 enqueues and no 429 — this door writes an object per call") } } // TestEnqueueIsScopedToItsOwnApp: an app-bound service credential reaches // exactly one app, and the refusal is the family's 404 rather than a 403 that // would confirm the other app exists. func TestEnqueueIsScopedToItsOwnApp(t *testing.T) { fx, _ := enqueueArmed(t, "code-enq-scope") if _, err := fx.st.CreateAppIn(OpsAccountID, "otherapp", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } tok := appToken(t, fx, "scopedbot") status, _ := postRaw(t, enqueueURL(fx, "otherapp"), map[string]any{"key": "uploads/x.pdf"}, tok) if status != http.StatusNotFound { t.Fatalf("cross-app enqueue = %d, want 404", status) } } // TestScanRefusalAnswerCoversEveryName is the table's own test, and it exists // because two of its arms cannot be reached through the door. // // A trashed app is a 404 at apiAccountApp before the consent chain is // consulted, and a refusal name objstore has not defined yet does not exist to // send. Both were mutation-tested through HTTP and both survived — not because // the code was wrong, but because no request can distinguish them. So they are // asserted where they ARE observable. // // The list is written out rather than derived: Go cannot enumerate the // constants of a string type, so a derived list would be this list anyway, // with the compiler no longer checking that each name still exists. func TestScanRefusalAnswerCoversEveryName(t *testing.T) { named := []objstore.QueueRefusal{ objstore.RefusalNotGranted, objstore.RefusalTrashed, objstore.RefusalNotRequested, objstore.RefusalEncrypted, objstore.RefusalNotEnabled, objstore.RefusalDisabled, } for _, r := range named { ans := scanRefusalAnswer(r) if !ans.refused() { t.Errorf("%q: the door does not refuse it — a consent refusal that enqueues is the hole this chain exists to close", r) } if ans.unknown { t.Errorf("%q: answered as an unrecognised name, so the door has not been taught it", r) } if ans.notFound { continue // the 404 arm carries no sentence, by design } if ans.status < 400 { t.Errorf("%q: status %d is not a refusal", r, ans.status) } if strings.TrimSpace(ans.msg) == "" { t.Errorf("%q: refused with no sentence beside the reason", r) } } // The trashed arm answers as the family's 404 and says nothing else. A // reason here would make this door the one that confirms an app exists. if ans := scanRefusalAnswer(objstore.RefusalTrashed); !ans.notFound || ans.status != 0 { t.Errorf("trashed = %+v, want the bare 404 apiAccountApp already gives", ans) } // RefusalNone is the ONLY value that means yes. if ans := scanRefusalAnswer(objstore.RefusalNone); ans.refused() { t.Errorf("RefusalNone = %+v, want no refusal at all", ans) } // A name objstore might add tomorrow. The door has never seen it, and the // answer must still be a refusal: costing a working feature is survivable, // letting an unscanned object through on an unrecognised refusal is not. ans := scanRefusalAnswer(objstore.QueueRefusal("some_future_reason")) if !ans.refused() { t.Fatal("an unrecognised refusal enqueues — a name added to objstore would silently open this door") } if !ans.unknown { t.Error("an unrecognised refusal is not flagged unknown, so nothing would be logged about it") } }