mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 15:56:58 +00:00
hold/pds: cover HandleUpdateCrewTier, which had no test
The hold end of the billing fan-out had no test at all -- only the ErrCrewMemberNotFound sentinel was covered. Its answer decides whether the Stripe webhook records an event as processed or retries it, so each status it can return means something different upstream and is covered separately: the applied path (asserting the stored crew record actually changed, not just the response body), not-crew as a successful no-op, the 403 on a body userDid that disagrees with the signed subject, an empty body userDid falling back to the token subject, 401 unsigned, 400 with no tiers configured, and rank clamping. Each was mutation-verified. One of them corrected the test's own comment: removing the 403 guard does not let a body retarget a grant, because every step after it keys off the token's sub claim and req.UserDID is read nowhere else. The guard makes a disagreeing body loud rather than silently ignored, and the stored-tier assertion is the regression guard for the day something reaches for that unsigned field when it needs "which user". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d30f6abb7
commit
4dd473bbf1
@@ -0,0 +1,262 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/hold/quota"
|
||||
)
|
||||
|
||||
// HandleUpdateCrewTier (xrpc.go) had no test at all. It is the hold end of the
|
||||
// billing fan-out: the appview calls it after Stripe reports a subscription
|
||||
// change, and its answer decides whether the webhook is recorded as processed
|
||||
// or retried. Each status it can return means something different upstream, so
|
||||
// they are covered individually here.
|
||||
|
||||
// newTierHandler wires an XRPC handler to a signing appview and a quota config
|
||||
// with three ranks, and returns the handler plus the appview env that can mint
|
||||
// tokens for it.
|
||||
func newTierHandler(t *testing.T) (*XRPCHandler, *appviewTestEnv) {
|
||||
t.Helper()
|
||||
|
||||
handler, _ := setupTestXRPCHandler(t)
|
||||
env := newAppviewTestEnv(t)
|
||||
// The token's audience must be this hold, not the placeholder the shared
|
||||
// env carries.
|
||||
env.holdDID = handler.pds.DID()
|
||||
handler.SetAppviewDID(env.appviewDID)
|
||||
|
||||
qm, err := quota.NewManagerFromConfig("a.Config{
|
||||
Tiers: []quota.TierConfig{
|
||||
{Name: "deckhand", Quota: "5GB"},
|
||||
{Name: "bosun", Quota: "50GB"},
|
||||
{Name: "quartermaster", Quota: "1TB"},
|
||||
},
|
||||
Defaults: quota.DefaultsConfig{NewCrewTier: "deckhand"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("quota manager: %v", err)
|
||||
}
|
||||
handler.quotaMgr = qm
|
||||
|
||||
return handler, env
|
||||
}
|
||||
|
||||
// tierRequest builds a signed updateCrewTier request. bodyUserDID is written
|
||||
// into the body verbatim, including empty, so the mismatch case can be built.
|
||||
func tierRequest(t *testing.T, env *appviewTestEnv, tokenSubDID, bodyUserDID string, rank int) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{"userDid": bodyUserDID, "tierRank": rank})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/xrpc/io.atcr.hold.updateCrewTier", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.sign(t, tokenSubDID))
|
||||
return req
|
||||
}
|
||||
|
||||
func decodeTierResponse(t *testing.T, rr *httptest.ResponseRecorder) (string, bool) {
|
||||
t.Helper()
|
||||
var got struct {
|
||||
TierName string `json:"tierName"`
|
||||
Applied bool `json:"applied"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v (body %q)", err, rr.Body.String())
|
||||
}
|
||||
return got.TierName, got.Applied
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_AppliesToCrewMember is the path that grants a tier.
|
||||
// The response is not enough on its own — it asserts the stored crew record
|
||||
// changed, since an applied:true over an unchanged record is the failure worth
|
||||
// catching.
|
||||
func TestHandleUpdateCrewTier_AppliesToCrewMember(t *testing.T) {
|
||||
handler, env := newTierHandler(t)
|
||||
ctx := t.Context()
|
||||
userDID := "did:plc:tiertestcrewmember00001"
|
||||
|
||||
if _, err := handler.pds.AddCrewMember(ctx, userDID, "member", []string{"blob:write"}, "deckhand"); err != nil {
|
||||
t.Fatalf("add crew member: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, tierRequest(t, env, userDID, userDID, 2))
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
tierName, applied := decodeTierResponse(t, rr)
|
||||
if tierName != "quartermaster" || !applied {
|
||||
t.Errorf("response = (%q, applied=%v), want (\"quartermaster\", applied=true)", tierName, applied)
|
||||
}
|
||||
|
||||
_, rec, err := handler.pds.GetCrewMemberByDID(ctx, userDID)
|
||||
if err != nil {
|
||||
t.Fatalf("re-read crew member: %v", err)
|
||||
}
|
||||
if rec.Tier != "quartermaster" {
|
||||
t.Errorf("stored tier = %q, want %q — the handler answered applied:true without writing", rec.Tier, "quartermaster")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_NotCrewIsSuccessfulNoOp covers the case that keeps
|
||||
// the Stripe webhook from retrying forever.
|
||||
//
|
||||
// The appview fans a tier update out to every managed hold, but a subscriber is
|
||||
// only crew on holds they have actually pushed to, and a brand-new one may be
|
||||
// crew nowhere. Answering anything but 200 here would fail the whole webhook on
|
||||
// holds where there is nothing to do.
|
||||
func TestHandleUpdateCrewTier_NotCrewIsSuccessfulNoOp(t *testing.T) {
|
||||
handler, env := newTierHandler(t)
|
||||
strangerDID := "did:plc:tiertestnotcrewatall001"
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, tierRequest(t, env, strangerDID, strangerDID, 2))
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
tierName, applied := decodeTierResponse(t, rr)
|
||||
if applied {
|
||||
t.Errorf("applied = true for a DID that is not crew on this hold")
|
||||
}
|
||||
if tierName != "quartermaster" {
|
||||
t.Errorf("tierName = %q, want the resolved name even when not applied", tierName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_BodyUserDIDMismatchIsForbidden holds the boundary
|
||||
// between the signed subject and the unsigned body.
|
||||
//
|
||||
// Be precise about what this buys today: every step after the guard — the DID
|
||||
// parse, the crew lookup, the tier write — keys off userDID from the token's
|
||||
// sub claim, and req.UserDID is read nowhere else. So deleting the guard does
|
||||
// not by itself let a body retarget a grant; it makes a disagreeing body be
|
||||
// silently ignored instead of rejected.
|
||||
//
|
||||
// The value is in keeping it that way. The body field is the obvious thing for
|
||||
// a later change to reach for when it needs "which user", and the moment one
|
||||
// does, an unsigned field becomes an addressing input. The stored-tier
|
||||
// assertion below is the regression guard for exactly that.
|
||||
func TestHandleUpdateCrewTier_BodyUserDIDMismatchIsForbidden(t *testing.T) {
|
||||
handler, env := newTierHandler(t)
|
||||
ctx := t.Context()
|
||||
attackerDID := "did:plc:tiertestattacker00000001"
|
||||
victimDID := "did:plc:tiertestvictim0000000001"
|
||||
|
||||
if _, err := handler.pds.AddCrewMember(ctx, victimDID, "member", []string{"blob:write"}, "deckhand"); err != nil {
|
||||
t.Fatalf("add crew member: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, tierRequest(t, env, attackerDID, victimDID, 2))
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
_, rec, err := handler.pds.GetCrewMemberByDID(ctx, victimDID)
|
||||
if err != nil {
|
||||
t.Fatalf("re-read victim: %v", err)
|
||||
}
|
||||
if rec.Tier != "deckhand" {
|
||||
t.Errorf("victim tier = %q, want %q — a body userDid reached the tier write", rec.Tier, "deckhand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_EmptyBodyUserDIDUsesTokenSubject: an absent userDid
|
||||
// is not a mismatch. The appview does send one, but the guard must key on
|
||||
// disagreement rather than on presence, or an older appview stops working.
|
||||
func TestHandleUpdateCrewTier_EmptyBodyUserDIDUsesTokenSubject(t *testing.T) {
|
||||
handler, env := newTierHandler(t)
|
||||
ctx := t.Context()
|
||||
userDID := "did:plc:tiertestemptybody00000001"
|
||||
|
||||
if _, err := handler.pds.AddCrewMember(ctx, userDID, "member", []string{"blob:write"}, "deckhand"); err != nil {
|
||||
t.Fatalf("add crew member: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, tierRequest(t, env, userDID, "", 1))
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, applied := decodeTierResponse(t, rr); !applied {
|
||||
t.Errorf("applied = false; an empty body userDid should fall back to the token subject")
|
||||
}
|
||||
_, rec, err := handler.pds.GetCrewMemberByDID(ctx, userDID)
|
||||
if err != nil {
|
||||
t.Fatalf("re-read crew member: %v", err)
|
||||
}
|
||||
if rec.Tier != "bosun" {
|
||||
t.Errorf("stored tier = %q, want %q", rec.Tier, "bosun")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_RejectsUnsignedRequest: no token means 401, not a
|
||||
// silent no-op. The endpoint writes entitlements, so an unauthenticated caller
|
||||
// must never reach the crew lookup.
|
||||
func TestHandleUpdateCrewTier_RejectsUnsignedRequest(t *testing.T) {
|
||||
handler, _ := newTierHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/xrpc/io.atcr.hold.updateCrewTier",
|
||||
strings.NewReader(`{"userDid":"did:plc:tiertestanon000000000001","tierRank":2}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_NoTiersConfiguredIsBadRequest: a hold with quotas
|
||||
// switched off cannot name a tier. 400 rather than 200 matters — the appview
|
||||
// must not read "no tiers here" as "tier granted".
|
||||
func TestHandleUpdateCrewTier_NoTiersConfiguredIsBadRequest(t *testing.T) {
|
||||
handler, env := newTierHandler(t)
|
||||
qm, err := quota.NewManagerFromConfig("a.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("quota manager: %v", err)
|
||||
}
|
||||
handler.quotaMgr = qm
|
||||
|
||||
userDID := "did:plc:tiertestnotiers000000001"
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, tierRequest(t, env, userDID, userDID, 2))
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateCrewTier_RankAboveHighestClamps: the appview's rank comes
|
||||
// from its own tier list, which need not be the same length as the hold's. An
|
||||
// out-of-range rank clamps to the highest tier rather than erroring, so a hold
|
||||
// with fewer tiers still grants the best it has.
|
||||
func TestHandleUpdateCrewTier_RankAboveHighestClamps(t *testing.T) {
|
||||
handler, env := newTierHandler(t)
|
||||
ctx := t.Context()
|
||||
userDID := "did:plc:tiertestclamp00000000001"
|
||||
|
||||
if _, err := handler.pds.AddCrewMember(ctx, userDID, "member", []string{"blob:write"}, "deckhand"); err != nil {
|
||||
t.Fatalf("add crew member: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleUpdateCrewTier(rr, tierRequest(t, env, userDID, userDID, 99))
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body %q)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if tierName, _ := decodeTierResponse(t, rr); tierName != "quartermaster" {
|
||||
t.Errorf("tierName = %q, want the highest configured tier", tierName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user