| new file mode 100644 |
| index 000000000..b90f85917 |
| --- /dev/null |
| +++ b/internal/console/apistore_entitlement_test.go |
| @@ -0,0 +1,511 @@ |
| +package console |
| + |
| +// The account-level object-storage entitlement doors (issue #381). These |
| +// tests pin the authority boundary first: only an account owner may apply, |
| +// while decisions belong to a deployment operator who need not be a member |
| +// of the account. They then pin the record transitions and the operator queue. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func entitlementApplyURL(fx *consoleFixture, account string) string { |
| + return fx.ts.URL + "/api/cli/accounts/" + account + "/store/apply" |
| +} |
| + |
| +func entitlementDecisionURL(fx *consoleFixture, account, decision string) string { |
| + return fx.ts.URL + "/api/cli/accounts/" + account + "/store/" + decision |
| +} |
| + |
| +func entitlementApprovalsURL(fx *consoleFixture) string { |
| + return fx.ts.URL + "/api/cli/store/approvals" |
| +} |
| + |
| +func storedEntitlement(t *testing.T, fx *consoleFixture, account string) *objstore.Entitlement { |
| + t.Helper() |
| + e, _, err := objstore.GetEntitlement(context.Background(), fx.rel, account) |
| + if err != nil { |
| + t.Fatalf("read entitlement for %s: %v", account, err) |
| + } |
| + return e |
| +} |
| + |
| +func putEntitlement(t *testing.T, fx *consoleFixture, account string, e *objstore.Entitlement) { |
| + t.Helper() |
| + if _, err := objstore.PutEntitlement(context.Background(), fx.rel, account, e, ""); err != nil { |
| + t.Fatalf("seed entitlement for %s: %v", account, err) |
| + } |
| +} |
| + |
| +func entitlementOperatorToken(t *testing.T, fx *consoleFixture, address, code string) string { |
| + t.Helper() |
| + if err := fx.st.SetOperator(address, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.StoreOperators = []string{address} |
| + return cliToken(t, fx, address, code) |
| +} |
| + |
| +func entitlementBlock(t *testing.T, out map[string]any) map[string]any { |
| + t.Helper() |
| + return block(t, out, "entitlement") |
| +} |
| + |
| +func TestAPIStoreApplyNeedsAnOwner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "member@keymail.dev", "code-store-apply-member") |
| + |
| + status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "uploads"}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("apply by a non-owner = %d, want 403", status) |
| + } |
| + if got := storedEntitlement(t, fx, fx.sqid); got != nil { |
| + t.Fatalf("refused apply wrote %+v", got) |
| + } |
| +} |
| + |
| +func TestAPIStoreApplyHidesUnknownAndOtherAccounts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + eventoken := cliToken(t, fx, "eve@keymail.dev", "code-store-apply-stranger") |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-unknown") |
| + |
| + statusA, bodyA := postRaw(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, eventoken) |
| + statusB, bodyB := postRaw(t, entitlementApplyURL(fx, "zzzz"), map[string]any{}, owner) |
| + if statusA != http.StatusNotFound || statusB != http.StatusNotFound { |
| + t.Fatalf("apply probes = %d / %d, want 404 for both", statusA, statusB) |
| + } |
| + if bodyA != bodyB || bodyA != "404 page not found\n" { |
| + t.Fatalf("apply probe bodies = %q / %q, want byte-identical stdlib 404", bodyA, bodyB) |
| + } |
| +} |
| + |
| +func TestAPIStoreApplyWritesAndRepliesWithTheEntitlement(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply") |
| + |
| + status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "customer uploads"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("apply = %d %v, want 200", status, out) |
| + } |
| + if str(t, out, "account") != fx.sqid { |
| + t.Fatalf("account = %v, want %s", out["account"], fx.sqid) |
| + } |
| + e := entitlementBlock(t, out) |
| + applied := block(t, e, "applied") |
| + if applied["by"] != "paul@keymail.dev" || applied["note"] != "customer uploads" { |
| + t.Fatalf("applied = %v", applied) |
| + } |
| + if e["decision"] != nil { |
| + t.Fatalf("decision = %v, want null while pending", e["decision"]) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got == nil || !got.Pending() || got.Applied.By != "paul@keymail.dev" || got.Applied.Note != "customer uploads" { |
| + t.Fatalf("stored entitlement = %+v, want the pending application", got) |
| + } |
| + auditedBy(t, fx, "", "store-apply", "paul@keymail.dev", "customer uploads") |
| +} |
| + |
| +func TestAPIStoreApplyReopensADeclineUsingCAS(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + oldApplied := time.Now().Add(-time.Hour).UTC() |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: oldApplied, Note: "first ask"}, |
| + Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute), Reason: "more detail needed"}, |
| + }) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-store-reapply") |
| + |
| + status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "expanded plan"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("re-apply = %d %v, want 200", status, out) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got == nil || !got.Pending() || got.Decision != nil { |
| + t.Fatalf("re-applied entitlement = %+v, want pending with the decision cleared", got) |
| + } |
| + if got.Applied.Note != "expanded plan" || !got.Applied.At.After(oldApplied) { |
| + t.Fatalf("re-applied block = %+v, want a fresh application", got.Applied) |
| + } |
| +} |
| + |
| +func TestAPIStoreApplySurfacesACASConflictWithoutOverwritingTheWinner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour), Note: "first ask"}, |
| + Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute), Reason: "revise"}, |
| + }) |
| + racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.EntitlementKey(fx.sqid)} |
| + racer.race = func(inner release.Store) { |
| + winner, etag, err := objstore.GetEntitlement(context.Background(), inner, fx.sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + winner.Applied.Note = "the concurrent winner" |
| + if _, err := objstore.PutEntitlement(context.Background(), inner, fx.sqid, winner, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + fx.srv.cfg.Releases = racer |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-cas") |
| + |
| + status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "the losing write"}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("apply losing its CAS = %d, want 409", status) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got.Applied.Note != "the concurrent winner" { |
| + t.Fatalf("application after conflict = %+v, want the concurrent winner preserved", got.Applied) |
| + } |
| +} |
| + |
| +func TestAPIStoreApplyUsesTheSharedWriteBudget(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-limit") |
| + |
| + if status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("first apply = %d %v, want 200", status, out) |
| + } |
| + if status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{}, token); status != http.StatusTooManyRequests { |
| + t.Fatalf("second apply on an exhausted budget = %d, want 429", status) |
| + } |
| +} |
| + |
| +func TestAPIStoreApplyToAnApprovedAccountIsANoOp(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + want := &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour).UTC(), Note: "original"}, |
| + Decision: &objstore.Decision{Approved: true, By: "ops@keymail.dev", At: time.Now().Add(-time.Minute).UTC()}, |
| + } |
| + putEntitlement(t, fx, fx.sqid, want) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-store-apply-approved") |
| + |
| + status, out := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "must not replace approval"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("apply when approved = %d %v, want 200", status, out) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got == nil || !got.Approved() || got.Decision.By != want.Decision.By || !got.Decision.At.Equal(want.Decision.At) { |
| + t.Fatalf("approved decision changed: got %+v, want %+v", got, want) |
| + } |
| + if got.Applied.Note != "original" || !got.Applied.At.Equal(want.Applied.At) { |
| + t.Fatalf("approved application changed: got %+v, want %+v", got.Applied, want.Applied) |
| + } |
| +} |
| + |
| +func TestAPIStoreDecisionNeedsBothOperatorAuthorities(t *testing.T) { |
| + tests := []struct { |
| + name string |
| + fleet bool |
| + allowed bool |
| + }{ |
| + {"ordinary user on allowlist", false, true}, |
| + {"fleet operator absent from allowlist", true, false}, |
| + } |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now()}}) |
| + const address = "candidate@keymail.dev" |
| + if tt.fleet { |
| + if err := fx.st.SetOperator(address, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tt.allowed { |
| + fx.srv.cfg.StoreOperators = []string{address} |
| + } |
| + token := cliToken(t, fx, address, "code-store-authority-"+tt.name) |
| + |
| + status, body := postRaw(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("approve with only one authority bit = %d %q, want bare 404", status, body) |
| + } |
| + status, body = getRaw(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("approvals with only one authority bit = %d %q, want bare 404", status, body) |
| + } |
| + if !storedEntitlement(t, fx, fx.sqid).Pending() { |
| + t.Fatal("refused operator changed the pending entitlement") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestAPIStoreOperatorMatchingIsExact(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + const lookalike = "owner@example.teſt" |
| + if err := fx.st.SetOperator(lookalike, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.StoreOperators = []string{"owner@example.test"} |
| + token := cliToken(t, fx, lookalike, "code-store-long-s") |
| + |
| + status, body := getRaw(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("lookalike operator = %d %q, want bare 404", status, body) |
| + } |
| +} |
| + |
| +func TestAPIStoreNonMemberOperatorCanApprove(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now(), Note: "backups"}}) |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approve-nonmember") |
| + role, err := fx.st.Role(OpsAccountID, "ops@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if role != "" { |
| + t.Fatalf("operator role in target account = %q, want none", role) |
| + } |
| + |
| + status, out := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("approve by non-member operator = %d %v, want 200", status, out) |
| + } |
| + if str(t, out, "account") != fx.sqid { |
| + t.Fatalf("account = %v, want %s", out["account"], fx.sqid) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got == nil || !got.Approved() || got.Decision.By != "ops@keymail.dev" || got.Decision.Reason != "" { |
| + t.Fatalf("approved entitlement = %+v", got) |
| + } |
| + auditedBy(t, fx, "", "store-approve", "ops@keymail.dev", "approved") |
| +} |
| + |
| +func TestAPIStoreOperatorDecisionsHideUnknownAccounts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decision-unknown") |
| + |
| + for _, decision := range []string{"approve", "decline"} { |
| + status, body := postRaw(t, entitlementDecisionURL(fx, "zzzz", decision), map[string]any{"reason": "irrelevant"}, token) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Errorf("%s unknown account = %d %q, want bare 404", decision, status, body) |
| + } |
| + } |
| +} |
| + |
| +func TestAPIStoreApproveNeedsAnApplication(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approve-empty") |
| + |
| + status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("approve without an application = %d, want 409", status) |
| + } |
| + if got := storedEntitlement(t, fx, fx.sqid); got != nil { |
| + t.Fatalf("approve without an application wrote %+v", got) |
| + } |
| +} |
| + |
| +func TestAPIStoreDeclineRequiresAReasonAndMayReplaceApproval(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour)}, |
| + Decision: &objstore.Decision{Approved: true, By: "first-op@keymail.dev", At: time.Now().Add(-time.Minute)}, |
| + }) |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decline") |
| + |
| + status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("decline without a reason = %d, want 400", status) |
| + } |
| + if !storedEntitlement(t, fx, fx.sqid).Approved() { |
| + t.Fatal("a refused decline changed the approval") |
| + } |
| + |
| + status, out := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{"reason": "capacity review"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("decline = %d %v, want 200", status, out) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got == nil || !got.Declined() || got.Decision.By != "ops@keymail.dev" || got.Decision.Reason != "capacity review" { |
| + t.Fatalf("declined entitlement = %+v", got) |
| + } |
| + auditedBy(t, fx, "", "store-decline", "ops@keymail.dev", "capacity review") |
| +} |
| + |
| +func TestAPIStoreDecisionSurfacesACASConflictWithoutOverwritingTheWinner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Hour)}, |
| + }) |
| + racer := &racingStore{Store: fx.rel, raceKeyOnce: objstore.EntitlementKey(fx.sqid)} |
| + racer.race = func(inner release.Store) { |
| + winner, etag, err := objstore.GetEntitlement(context.Background(), inner, fx.sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + winner.Decision = &objstore.Decision{Approved: false, By: "winner@keymail.dev", At: time.Now(), Reason: "winner's decision"} |
| + if _, err := objstore.PutEntitlement(context.Background(), inner, fx.sqid, winner, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + fx.srv.cfg.Releases = racer |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-decision-cas") |
| + |
| + status, _ := postJSON(t, entitlementDecisionURL(fx, fx.sqid, "approve"), map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("decision losing its CAS = %d, want 409", status) |
| + } |
| + got := storedEntitlement(t, fx, fx.sqid) |
| + if got == nil || !got.Declined() || got.Decision.By != "winner@keymail.dev" { |
| + t.Fatalf("entitlement after conflict = %+v, want the concurrent winner preserved", got) |
| + } |
| +} |
| + |
| +func TestAPIStoreApprovalsListsPendingOldestFirst(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now().UTC() |
| + const secondID int64 = 4242 |
| + secondSqid := accountSqid(secondID) |
| + if err := fx.st.CreateAccount(secondID, secondSqid, "Second", "second@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + putEntitlement(t, fx, secondSqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "second@keymail.dev", At: now.Add(-2 * time.Hour), Note: "oldest"}}) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: now.Add(-time.Hour), Note: "newer"}}) |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-order") |
| + |
| + status, out := getJSON(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("approvals = %d %v, want 200", status, out) |
| + } |
| + raw, ok := out["applications"].([]any) |
| + if !ok || len(raw) != 2 { |
| + t.Fatalf("applications = %#v, want two entries", out["applications"]) |
| + } |
| + first, ok := raw[0].(map[string]any) |
| + if !ok { |
| + t.Fatalf("applications[0] = %#v, want an object", raw[0]) |
| + } |
| + second, ok := raw[1].(map[string]any) |
| + if !ok { |
| + t.Fatalf("applications[1] = %#v, want an object", raw[1]) |
| + } |
| + if first["account"] != secondSqid || second["account"] != fx.sqid { |
| + t.Fatalf("application order = %v then %v, want %s then %s", first["account"], second["account"], secondSqid, fx.sqid) |
| + } |
| + if _, ok := first["applied"].(map[string]any); !ok { |
| + t.Fatalf("first application = %#v, want an entitlement object", first) |
| + } |
| +} |
| + |
| +func TestAPIStoreApprovalsFiltersDecidedDeletedAndUnknownAccounts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now().UTC() |
| + |
| + const deletedID int64 = 4343 |
| + deletedSqid := accountSqid(deletedID) |
| + if err := fx.st.CreateAccount(deletedID, deletedSqid, "Deleted", "deleted@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.DeleteAccount(deletedID, now.Add(time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + putEntitlement(t, fx, deletedSqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "deleted@keymail.dev", At: now.Add(-3 * time.Hour)}}) |
| + putEntitlement(t, fx, "bde", &objstore.Entitlement{Applied: &objstore.Applied{By: "orphan@keymail.dev", At: now.Add(-2 * time.Hour)}}) |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: now.Add(-time.Hour)}, |
| + Decision: &objstore.Decision{Approved: false, By: "ops@keymail.dev", At: now, Reason: "decided"}, |
| + }) |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-filter") |
| + |
| + status, out := getJSON(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("approvals = %d %v, want 200", status, out) |
| + } |
| + raw, ok := out["applications"].([]any) |
| + if !ok { |
| + t.Fatalf("applications = %#v, want a list", out["applications"]) |
| + } |
| + if len(raw) != 0 { |
| + t.Fatalf("applications = %#v, want decided, deleted, and unknown accounts filtered", raw) |
| + } |
| +} |
| + |
| +type entitlementListErrorStore struct{ release.Store } |
| + |
| +func (s entitlementListErrorStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if prefix == "store/entitlements/" { |
| + return nil, errors.New("list unavailable") |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +func TestAPIStoreApprovalsFailsWhenTheQueueCannotBeListed(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.Releases = entitlementListErrorStore{Store: fx.rel} |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-list-error") |
| + |
| + status, _ := getJSON(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("approvals with a failed list = %d, want 502", status) |
| + } |
| +} |
| + |
| +func TestAPIStoreApprovalsFailsOnAMalformedEntitlement(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-malformed") |
| + |
| + status, _ := getJSON(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("approvals with malformed entitlement = %d, want 502", status) |
| + } |
| +} |
| + |
| +func TestAPIStoreApprovalsIgnoresNonCanonicalKeys(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.rel.Put(context.Background(), "store/entitlements/nested/bab.json", []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), "store/entitlements/readme", []byte("not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-keys") |
| + |
| + status, out := getJSON(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("approvals with unrelated keys = %d %v, want 200", status, out) |
| + } |
| + if raw, ok := out["applications"].([]any); !ok || len(raw) != 0 { |
| + t.Fatalf("applications = %#v, want an empty list", out["applications"]) |
| + } |
| +} |
| + |
| +func TestAPIStoreApprovalsTrustsTheKeyForTheAccount(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now().UTC() |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{Applied: &objstore.Applied{By: "paul@keymail.dev", At: now}}) |
| + raw, etag, err := fx.rel.Get(context.Background(), objstore.EntitlementKey(fx.sqid)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw = []byte(strings.Replace(string(raw), `"account": "`+fx.sqid+`"`, `"account": "zzz"`, 1)) |
| + if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), raw, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := entitlementOperatorToken(t, fx, "ops@keymail.dev", "code-store-approvals-account") |
| + |
| + status, out := getJSON(t, entitlementApprovalsURL(fx), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("approvals = %d %v, want 200", status, out) |
| + } |
| + applications := out["applications"].([]any) |
| + got := applications[0].(map[string]any)["account"] |
| + if got != fx.sqid { |
| + t.Fatalf("application account = %v, want key account %s", got, fx.sqid) |
| + } |
| +} |