diff --git a/pkg/hold/pds/crew_tier_test.go b/pkg/hold/pds/crew_tier_test.go new file mode 100644 index 0000000..6f48411 --- /dev/null +++ b/pkg/hold/pds/crew_tier_test.go @@ -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) + } +}