| new file mode 100644 |
| index 000000000..edb5dfcbb |
| --- /dev/null |
| +++ b/internal/console/apiinstances_test.go |
| @@ -0,0 +1,664 @@ |
| +package console |
| + |
| +// The instance API (apiinstances.go): the console's provisioning verbs and |
| +// the app-held token that may speak to them. The properties that matter, in |
| +// the order they'd hurt if lost: every refusal before the principal is |
| +// proven is the family's byte-identical 404 (a probe learns nothing — not |
| +// whether an account exists, not whether an app does, not whether an app |
| +// token's binding is close to right), an app token is contained to its own |
| +// account+app and can mint nothing, no host reaches a bucket key without |
| +// passing the label+suffix check, and the rate limiter is consulted only |
| +// AFTER the gate so a 429 can never confirm what a 404 hides. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// rawReq is the tests' one-shot request: any method, an optional bearer and |
| +// an optional JSON body, and the response handed back as status + headers + |
| +// bytes so a refusal can be compared to http.NotFound's own output field by |
| +// field rather than merely "looks like a 404". |
| +func rawReq(t *testing.T, method, u, bearer, body string) (int, http.Header, string) { |
| + t.Helper() |
| + var rdr io.Reader |
| + if body != "" { |
| + rdr = strings.NewReader(body) |
| + } |
| + req, err := http.NewRequest(method, u, rdr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if body != "" { |
| + req.Header.Set("Content-Type", "application/json") |
| + } |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + out, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return res.StatusCode, res.Header, string(out) |
| +} |
| + |
| +// notFound404 is one refusal reduced to the parts a prober could read: the |
| +// status, the two headers http.Error sets, and the body. |
| +type notFound404 struct { |
| + Status int |
| + Type string |
| + NoSniff string |
| + Body string |
| +} |
| + |
| +// canonicalNotFound captures what the STDLIB writes, rather than typing the |
| +// expected bytes out here: the property under test is "identical to |
| +// http.NotFound", so http.NotFound is what the tests must compare against. |
| +func canonicalNotFound(t *testing.T) notFound404 { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + http.NotFound(rec, httptest.NewRequest(http.MethodGet, "/", nil)) |
| + return notFound404{rec.Code, rec.Header().Get("Content-Type"), |
| + rec.Header().Get("X-Content-Type-Options"), rec.Body.String()} |
| +} |
| + |
| +func assertNotFound(t *testing.T, what string, want notFound404, status int, h http.Header, body string) { |
| + t.Helper() |
| + got := notFound404{status, h.Get("Content-Type"), h.Get("X-Content-Type-Options"), body} |
| + if got != want { |
| + t.Errorf("%s\n got: %+v\nwant: the byte-identical %+v", what, got, want) |
| + } |
| +} |
| + |
| +// instanceFixture is a console with titogo claimed in the ops account and a |
| +// MEMBER (not owner) token for it — member-level is exactly the gate these |
| +// verbs grant, the same one promote and the ledger API use. |
| +func instanceFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// enableInstances writes the app's manifest — the operator act (`carlos |
| +// instances enable`) whose ABSENCE is what makes the feature not exist. |
| +func enableInstances(t *testing.T, fx *consoleFixture, app string, m provision.Manifest) { |
| + t.Helper() |
| + if err := provision.PutManifest(context.Background(), fx.rel, release.OpsAccount, app, m); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func titoManifest() provision.Manifest { |
| + return provision.Manifest{Schema: 1, App: "titogo", Domain: "go.tito.io", |
| + Pool: "tito", Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed"}} |
| +} |
| + |
| +func instancesURL(fx *consoleFixture, acct, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances" |
| +} |
| + |
| +// TestAPIInstanceRefusalMatrix: every way of not being allowed here — a |
| +// stranger's token, an app token pointed at a neighbouring account or a |
| +// neighbouring app, an account that does not exist, an app that was never |
| +// claimed — answers with the SAME bytes http.NotFound writes. Asserted |
| +// against the stdlib's own output, on every verb. |
| +func TestAPIInstanceRefusalMatrix(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-refusal") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + want := canonicalNotFound(t) |
| + |
| + // A second account, so the app token's cross-account probe names one |
| + // that genuinely exists: the refusal must not depend on that. |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(42, "cdef", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(42, "titogo", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // An app token bound to (bab, titogo) — the honest binding. |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // A member of nothing at all. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-eve") |
| + |
| + body := `{"host":"acme.go.tito.io","region":"ie"}` |
| + cases := []struct { |
| + what string |
| + method string |
| + url string |
| + token string |
| + body string |
| + }{ |
| + {"non-member list", http.MethodGet, instancesURL(fx, "bab", "titogo"), eve, ""}, |
| + {"non-member create", http.MethodPost, instancesURL(fx, "bab", "titogo"), eve, body}, |
| + {"non-member get", http.MethodGet, instancesURL(fx, "bab", "titogo") + "/acme.go.tito.io", eve, ""}, |
| + {"non-member delete", http.MethodDelete, instancesURL(fx, "bab", "titogo") + "/acme.go.tito.io", eve, ""}, |
| + {"app token, foreign account", http.MethodGet, instancesURL(fx, "cdef", "titogo"), appTok, ""}, |
| + {"app token, foreign account create", http.MethodPost, instancesURL(fx, "cdef", "titogo"), appTok, body}, |
| + {"app token, foreign app", http.MethodGet, instancesURL(fx, "bab", "other"), appTok, ""}, |
| + {"app token, foreign app create", http.MethodPost, instancesURL(fx, "bab", "other"), appTok, body}, |
| + // The account segment resolves by NAME for a user token; an app |
| + // token holds no memberships to resolve one against, so its own |
| + // account's name is not a way in either. |
| + {"app token, own account by name", http.MethodGet, instancesURL(fx, "ops", "titogo"), appTok, ""}, |
| + {"unknown account", http.MethodGet, instancesURL(fx, "zzzz", "titogo"), member, ""}, |
| + {"unclaimed app", http.MethodGet, instancesURL(fx, "bab", "ghost-app"), member, ""}, |
| + {"unclaimed app create", http.MethodPost, instancesURL(fx, "bab", "ghost-app"), member, body}, |
| + } |
| + for _, c := range cases { |
| + status, h, out := rawReq(t, c.method, c.url, c.token, c.body) |
| + assertNotFound(t, c.what, want, status, h, out) |
| + } |
| + |
| + // None of the refused calls wrote anything. |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.RecordKey("bab", "titogo", "acme.go.tito.io")); err == nil { |
| + t.Fatalf("a record exists after only refused creates") |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.RecordKey("cdef", "titogo", "acme.go.tito.io")); err == nil { |
| + t.Fatalf("a record exists in the neighbouring account after only refused creates") |
| + } |
| +} |
| + |
| +// TestAPIInstanceNoToken: no credential at all is the 401 every CLI door |
| +// gives, on all five routes. |
| +func TestAPIInstanceNoToken(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-inst-notoken") |
| + for _, c := range []struct{ method, url, body string }{ |
| + {http.MethodGet, instancesURL(fx, "bab", "titogo"), ""}, |
| + {http.MethodPost, instancesURL(fx, "bab", "titogo"), `{"host":"a.go.tito.io"}`}, |
| + {http.MethodGet, instancesURL(fx, "bab", "titogo") + "/a.go.tito.io", ""}, |
| + {http.MethodDelete, instancesURL(fx, "bab", "titogo") + "/a.go.tito.io", ""}, |
| + {http.MethodPost, fx.ts.URL + "/api/cli/apps/bab/titogo/instance-token", `{}`}, |
| + } { |
| + if status, _, out := rawReq(t, c.method, c.url, "", c.body); status != http.StatusUnauthorized { |
| + t.Errorf("%s %s with no token = %d %s, want 401", c.method, c.url, status, out) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIInstanceAppTokenContainment: the app token is contained in both |
| +// directions. It cannot speak to the verbs a person's token speaks to |
| +// (promote), it cannot mint another token, and it CAN do exactly the four |
| +// instance verbs on its own app. |
| +func TestAPIInstanceAppTokenContainment(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-inst-contain") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Promote: an app token is unknown to CLITokenAddress, so it meets the |
| + // same 401 a fabricated token does — never a 403 or a 404 that would |
| + // tell it the door exists for someone. |
| + if status, _, out := rawReq(t, http.MethodPost, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/promote", appTok, |
| + `{"version":"1.0.0","channel":"canary/a"}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token on promote = %d %s, want 401", status, out) |
| + } |
| + // Minting is a human act: the mint door is apiAccountApp's, and |
| + // apiAccountApp can never see an app token at all. |
| + if status, _, out := rawReq(t, http.MethodPost, |
| + fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", appTok, `{}`); status != http.StatusUnauthorized { |
| + t.Fatalf("app token minting a token = %d %s, want 401", status, out) |
| + } |
| + // Its own app's verbs work. |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, |
| + `{"host":"acme.go.tito.io","region":"ie"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("app token create on its own app = %d %s, want 201", status, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatalf("create body %s: %v", out, err) |
| + } |
| + if rec.CreatedBy != "app:bab/titogo" { |
| + t.Fatalf("created_by = %q, want the app principal app:bab/titogo", rec.CreatedBy) |
| + } |
| + if status, _, out := rawReq(t, http.MethodGet, instancesURL(fx, "bab", "titogo"), appTok, ""); status != http.StatusOK { |
| + t.Fatalf("app token list = %d %s, want 200", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, |
| + instancesURL(fx, "bab", "titogo")+"/acme.go.tito.io", appTok, ""); status != http.StatusOK { |
| + t.Fatalf("app token delete = %d %s, want 200", status, out) |
| + } |
| +} |
| + |
| +// TestAPIInstanceCreateRefusalOrder walks spec §7's order with a member |
| +// token: membership is already proven, so from here on refusals may name |
| +// their cause — and each one must be the RIGHT cause, in the right order. |
| +func TestAPIInstanceCreateRefusalOrder(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-create") |
| + u := instancesURL(fx, "bab", "titogo") |
| + |
| + // No manifest at all: the feature does not exist for this app. |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("create with no manifest = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "carlos instances enable") { |
| + t.Fatalf("no-manifest refusal = %s, want it to name `carlos instances enable`", out) |
| + } |
| + |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + |
| + for _, c := range []struct{ what, body string }{ |
| + {"bad host shape", `{"host":"-nope-.go.tito.io","region":"ie"}`}, |
| + {"traversal host", `{"host":"../../etc/passwd","region":"ie"}`}, |
| + {"wrong suffix", `{"host":"acme.evil.example","region":"ie"}`}, |
| + {"bare domain, no label", `{"host":"go.tito.io","region":"ie"}`}, |
| + {"region outside the allow-list", `{"host":"acme.go.tito.io","region":"us"}`}, |
| + {"malformed region", `{"host":"acme.go.tito.io","region":"IE/../x"}`}, |
| + {"bad channel", `{"host":"acme.go.tito.io","region":"ie","channel":"../etc"}`}, |
| + } { |
| + status, _, out := rawReq(t, http.MethodPost, u, token, c.body) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("%s = %d %s, want 400", c.what, status, out) |
| + } |
| + } |
| + // Nothing above reached the bucket. |
| + keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(keys) != 0 { |
| + t.Fatalf("refused creates left %v in the bucket", keys) |
| + } |
| + |
| + // The happy path: channel defaults from the manifest, pool is stamped |
| + // from it (never from the request), the record comes back whole. |
| + status, _, out = rawReq(t, http.MethodPost, u, token, |
| + `{"host":"acme.go.tito.io","region":"ie","pool":"attacker"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create = %d %s, want 201", status, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatalf("create body %s: %v", out, err) |
| + } |
| + if rec.Schema != 1 || rec.Host != "acme.go.tito.io" || rec.Slug != "acme" || |
| + rec.App != "titogo" || rec.Account != "bab" || rec.Pool != "tito" || |
| + rec.Region != "ie" || rec.Channel != "stable" { |
| + t.Fatalf("record = %+v", rec) |
| + } |
| + if rec.CreatedBy != "alice@keymail.dev" { |
| + t.Fatalf("created_by = %q, want the member's address", rec.CreatedBy) |
| + } |
| + |
| + // It is in the bucket at exactly the key the reconciler will look for. |
| + raw, _, err := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "acme.go.tito.io")) |
| + if err != nil { |
| + t.Fatalf("stored record: %v", err) |
| + } |
| + var stored provision.Record |
| + if err := json.Unmarshal(raw, &stored); err != nil { |
| + t.Fatal(err) |
| + } |
| + if stored.CreatedBy != "alice@keymail.dev" || stored.Host != "acme.go.tito.io" { |
| + t.Fatalf("stored record = %+v", stored) |
| + } |
| + if stored.CreatedAt.IsZero() { |
| + t.Fatalf("stored record has no created_at: %+v", stored) |
| + } |
| + |
| + // The same host twice is a lost race, not a silent overwrite. |
| + status, _, out = rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`) |
| + if status != http.StatusConflict { |
| + t.Fatalf("duplicate create = %d %s, want 409", status, out) |
| + } |
| + if !strings.Contains(out, "already exists") { |
| + t.Fatalf("duplicate refusal = %s, want it to say the instance already exists", out) |
| + } |
| + |
| + // An explicit channel is honoured over the manifest's default. |
| + status, _, out = rawReq(t, http.MethodPost, u, token, |
| + `{"host":"beta.go.tito.io","region":"ie","channel":"canary/beta"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create with channel = %d %s, want 201", status, out) |
| + } |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Channel != "canary/beta" { |
| + t.Fatalf("channel = %q, want the requested canary/beta", rec.Channel) |
| + } |
| +} |
| + |
| +// TestAPIInstanceCreateEmptyRegionAllowList: an empty allow-list means any |
| +// shape-valid region, per §3.1 — including the unconstrained empty one. |
| +func TestAPIInstanceCreateEmptyRegionAllowList(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-anyregion") |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Channel = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"us-east"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("create with an open allow-list = %d %s, want 201", status, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No manifest channel either: stable is the documented default. |
| + if rec.Channel != "stable" { |
| + t.Fatalf("channel = %q, want the stable default", rec.Channel) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"beta.go.tito.io"}`); status != http.StatusCreated { |
| + t.Fatalf("create with no region = %d %s, want 201", status, out) |
| + } |
| +} |
| + |
| +// TestAPIInstanceListAndGet: what was created lists and reads back, and a |
| +// host that isn't there is the byte-identical 404 — a record's existence is |
| +// not something to probe for either. |
| +func TestAPIInstanceListAndGet(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-list") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); status != http.StatusCreated { |
| + t.Fatalf("create %s = %d %s", h, status, out) |
| + } |
| + } |
| + |
| + status, _, out := rawReq(t, http.MethodGet, u, token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("list = %d %s", status, out) |
| + } |
| + var list struct { |
| + Instances []provision.Record `json:"instances"` |
| + Truncated bool `json:"truncated"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &list); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + if len(list.Instances) != 2 || list.Truncated { |
| + t.Fatalf("list = %+v, want both records and truncated false", list) |
| + } |
| + if list.Instances[0].Host != "acme.go.tito.io" || list.Instances[1].Host != "beta.go.tito.io" { |
| + t.Fatalf("list order = %+v, want host order", list.Instances) |
| + } |
| + |
| + status, _, out = rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("get = %d %s", status, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Host != "acme.go.tito.io" || rec.Slug != "acme" { |
| + t.Fatalf("get = %+v", rec) |
| + } |
| + |
| + want := canonicalNotFound(t) |
| + status, h, out := rawReq(t, http.MethodGet, u+"/missing.go.tito.io", token, "") |
| + assertNotFound(t, "get of a host with no record", want, status, h, out) |
| + // A hostile host never reaches the key builder: same 404, nothing read. |
| + status, h, out = rawReq(t, http.MethodGet, u+"/..%2f..%2fchannels", token, "") |
| + assertNotFound(t, "get of a traversal host", want, status, h, out) |
| +} |
| + |
| +// TestAPIInstanceDelete: deletion is idempotent (the caller's goal is "not |
| +// there"), survives the manifest being retired, and still refuses a host |
| +// that isn't one. |
| +func TestAPIInstanceDelete(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-delete") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + |
| + // A hostile host is refused before the key builder, and deletes nothing. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusBadRequest { |
| + t.Fatalf("delete of a traversal host = %d %s, want 400", status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), provision.ManifestKey("bab", "titogo")); err != nil { |
| + t.Fatalf("the manifest was destroyed by a traversal delete: %v", err) |
| + } |
| + |
| + status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, "") |
| + if status != http.StatusOK || !strings.Contains(out, "deleted") { |
| + t.Fatalf("delete = %d %s, want 200 deleted", status, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), |
| + provision.RecordKey("bab", "titogo", "acme.go.tito.io")); err == nil { |
| + t.Fatalf("the record survived its delete") |
| + } |
| + // Again: still 200. "Already gone" is the goal, reached. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, ""); status != http.StatusOK { |
| + t.Fatalf("second delete = %d %s, want an idempotent 200", status, out) |
| + } |
| + |
| + // Deletion outlives the manifest: an app that retired instances must |
| + // still be able to clean up the records it left behind. |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"beta.go.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + if err := fx.rel.Delete(context.Background(), provision.ManifestKey("bab", "titogo")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/beta.go.tito.io", token, ""); status != http.StatusOK { |
| + t.Fatalf("delete with the manifest retired = %d %s, want 200", status, out) |
| + } |
| + // And the shape check still applies with no domain to check against. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2fchannels", token, ""); status != http.StatusBadRequest { |
| + t.Fatalf("traversal delete with no manifest = %d %s, want 400", status, out) |
| + } |
| +} |
| + |
| +// TestAPIInstanceTokenMintEndToEnd is the whole app-token story: a member |
| +// mints one, the raw token is shown exactly once, and it then provisions on |
| +// its own — and every mutation left an audit row naming its principal. |
| +func TestAPIInstanceTokenMintEndToEnd(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-mint") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + |
| + status, _, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", |
| + token, `{"label":"tito signup"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("mint = %d %s, want 201", status, out) |
| + } |
| + var minted struct { |
| + Token string `json:"token"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &minted); err != nil { |
| + t.Fatalf("mint body %s: %v", out, err) |
| + } |
| + if minted.Token == "" || minted.Account != "bab" || minted.App != "titogo" { |
| + t.Fatalf("mint = %+v, want a token bound to bab/titogo", minted) |
| + } |
| + |
| + // The minted token provisions, and nothing else. |
| + u := instancesURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, minted.Token, |
| + `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create with the minted token = %d %s, want 201", status, out) |
| + } |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", minted.Token, ""); status != http.StatusOK { |
| + t.Fatalf("delete with the minted token = %d %s, want 200", status, out) |
| + } |
| + |
| + // The audit trail: three actions, each suffixed " (cli)", the create and |
| + // the delete attributed to the APP principal and the mint to the human. |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + seen := map[string]string{} |
| + for _, e := range events { |
| + seen[e.Action] = e.Address |
| + if !strings.HasSuffix(e.Detail, " (cli)") { |
| + t.Errorf("audit %s detail = %q, want the family's \" (cli)\" suffix", e.Action, e.Detail) |
| + } |
| + } |
| + if seen["instance-token"] != "alice@keymail.dev" { |
| + t.Errorf("instance-token actor = %q, want the minting member", seen["instance-token"]) |
| + } |
| + if seen["instance-create"] != "app:bab/titogo" { |
| + t.Errorf("instance-create actor = %q, want app:bab/titogo", seen["instance-create"]) |
| + } |
| + if seen["instance-delete"] != "app:bab/titogo" { |
| + t.Errorf("instance-delete actor = %q, want app:bab/titogo", seen["instance-delete"]) |
| + } |
| + |
| + // The minted credential lists under the human who minted it — which is |
| + // what makes revoking it possible at all. |
| + toks, err := fx.st.CLITokens("alice@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, tk := range toks { |
| + if tk.Kind == "app" { |
| + found = true |
| + if tk.App != "titogo" { |
| + t.Errorf("app token row = %+v, want it bound to titogo", tk) |
| + } |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("the minted app token does not list under its minter: %+v", toks) |
| + } |
| +} |
| + |
| +// TestAPIInstanceRateLimit: the instance budget is its own, keyed by token, |
| +// and — the property that matters — it is consulted only AFTER the |
| +// principal gate, so a 429 can never confirm what the 404 hides. |
| +func TestAPIInstanceRateLimit(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-ratelimit") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + |
| + for i := 0; i < cliInstanceBurst; i++ { |
| + body := fmt.Sprintf(`{"host":"host%d.go.tito.io","region":"ie"}`, i) |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, body); status != http.StatusCreated { |
| + t.Fatalf("create %d = %d %s, want 201 inside the budget", i, status, out) |
| + } |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"over.go.tito.io","region":"ie"}`); status != http.StatusTooManyRequests { |
| + t.Fatalf("create past the budget = %d %s, want 429", status, out) |
| + } |
| + // A stranger's refusal never changes shape, however many times they |
| + // ask. This is the gate-before-budget ordering asserted from outside: |
| + // past the burst, a limiter consulted FIRST would start answering 429 |
| + // where the 404 belongs — a different answer on the same door, which is |
| + // a difference a prober can measure and a refusal that has stopped |
| + // being byte-identical to every other refusal here. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-inst-rl-eve") |
| + want := canonicalNotFound(t) |
| + for i := 0; i < cliInstanceBurst+2; i++ { |
| + status, h, out := rawReq(t, http.MethodPost, u, eve, `{"host":"x.go.tito.io","region":"ie"}`) |
| + assertNotFound(t, fmt.Sprintf("non-member probe %d", i), want, status, h, out) |
| + if t.Failed() { |
| + break |
| + } |
| + } |
| + // The write budget minting uses is a different one: the member above |
| + // spent the instance budget, not this. |
| + if status, _, out := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/instance-token", |
| + token, `{}`); status != http.StatusCreated { |
| + t.Fatalf("mint after spending the instance budget = %d %s, want 201", status, out) |
| + } |
| +} |
| + |
| +// TestAPIInstanceNoStore: a console running without a deployment bucket |
| +// answers 503 — the shape promote's key check has — rather than panicking |
| +// on a nil store. |
| +func TestAPIInstanceNoStore(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-nostore") |
| + fx.srv.cfg.Releases = nil |
| + u := instancesURL(fx, "bab", "titogo") |
| + for _, c := range []struct{ what, method, url, body string }{ |
| + {"list", http.MethodGet, u, ""}, |
| + {"create", http.MethodPost, u, `{"host":"acme.go.tito.io","region":"ie"}`}, |
| + {"get", http.MethodGet, u + "/acme.go.tito.io", ""}, |
| + {"delete", http.MethodDelete, u + "/acme.go.tito.io", ""}, |
| + } { |
| + if status, _, out := rawReq(t, c.method, c.url, token, c.body); status != http.StatusServiceUnavailable { |
| + t.Errorf("%s with no bucket = %d %s, want 503", c.what, status, out) |
| + } |
| + } |
| +} |
| + |
| +// unreadableStore is a bucket whose reads fail — distinct from a MISSING |
| +// manifest, which is an answer ("instances are not enabled") rather than a |
| +// failure. Conflating the two would tell an app its feature was switched |
| +// off every time the bucket hiccuped. |
| +type unreadableStore struct{ release.Store } |
| + |
| +func (u unreadableStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errors.New(brokenStoreErr) |
| +} |
| + |
| +// TestAPIInstanceStoreFailure: a bucket that cannot be read or written is a |
| +// 502, and its error text — bucket names, request ids — never reaches the |
| +// caller. A read failure is NOT reported as "instances are not enabled". |
| +func TestAPIInstanceStoreFailure(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-broken") |
| + enableInstances(t, fx, "titogo", titoManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + |
| + fx.srv.cfg.Releases = brokenStore{fx.rel} |
| + status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("create against an unwritable bucket = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "carlos-prod-secret-bucket") || strings.Contains(out, "ABC123") { |
| + t.Fatalf("refusal leaks the store's error text: %s", out) |
| + } |
| + |
| + fx.srv.cfg.Releases = unreadableStore{fx.rel} |
| + status, _, out = rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("create against an unreadable bucket = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "instances enable") { |
| + t.Fatalf("a read failure was reported as the feature being disabled: %s", out) |
| + } |
| + status, _, out = rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, "") |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("delete against an unreadable bucket = %d %s, want 502", status, out) |
| + } |
| + if strings.Contains(out, "carlos-prod-secret-bucket") { |
| + t.Fatalf("delete refusal leaks the store's error text: %s", out) |
| + } |
| +} |