Files
at-container-registry/pkg/billing/tier_resolution_test.go
T
Evan JarrettandClaude Fable 5.1 a01b08b924 atproto: gate local indigo behavior behind a testmode build tag
indigo's identity directory refuses HTTP and IP-hosted did:web, and its
OAuth client is growing an SSRF-guarded transport that refuses loopback
and private addresses. Local development and the test suites need both,
and the workarounds were scattered: two did:web fallbacks in the
resolver, a hand-rolled appview key fetch on the hold, and the OAuth
client left on indigo's defaults so any test driving it against an
httptest server depended on the transport staying permissive.

Move every departure from indigo's defaults into one file pair in
pkg/atproto: indigo_prod.go (!testmode) returns indigo's directory and
OAuth client unchanged; indigo_local.go (testmode) wraps the directory
so a did:web naming an IP, localhost, or a host with a port resolves
over plain HTTP, and gives the OAuth client plain HTTP clients. All six
identity and OAuth constructor call sites go through NewDirectory and
NewOAuthClientApp. The resolver fallbacks, DIDWebToURL, and the hold's
scheme-guessing key fetch are gone; the hold resolves the appview key
through the directory, preferring #appview, and purges and retries once
on a signature failure so a re-keyed appview is not masked by the
24-hour cache.

There is no runtime switch for this: a production binary cannot be
configured to resolve local DIDs. The runtime test_mode flag still
gates the remaining behavioral branches only.

Tests, the harness, make dev, Air, Dockerfile.dev, and docker-compose
build with the tag; fixtures that need loopback did:web fail fast
naming it. Test hold servers now serve a did.json via pkg/testpds so
they resolve as real holds under the tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 10:53:27 -05:00

200 lines
7.0 KiB
Go

//go:build billing
package billing
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/testpds"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/stripe/stripe-go/v84"
)
// Tier resolution used to key on the price ID alone, which quietly inverted
// what a price change is supposed to do. Stripe prices are immutable, so
// raising a price means creating a new one, and Stripe never moves existing
// subscribers off the old one. Matching on price therefore dropped exactly the
// subscribers a price change is meant to leave untouched — and dropped them
// into the silent branch, where the event is answered 200, recorded as
// processed, and can never be redelivered.
func productPrice(priceID, productID string) *stripe.Price {
p := &stripe.Price{ID: priceID}
if productID != "" {
p.Product = &stripe.Product{ID: productID}
}
return p
}
func tierTestConfig() *Config {
return &Config{Tiers: []BillingTierConfig{
{Name: "free"},
{
Name: "supporter",
StripeProduct: "prod_supporter",
StripePriceMonthly: "price_supporter_v2",
},
{
Name: "pro",
StripePriceMonthly: "price_pro_only",
},
}}
}
// TestResolveTier_GrandfatheredPriceResolvesByProduct is the whole point of
// keying on the product: a subscriber still billing on price_supporter_v1,
// which no longer appears anywhere in the config, keeps their tier.
func TestResolveTier_GrandfatheredPriceResolvesByProduct(t *testing.T) {
m := &Manager{cfg: tierTestConfig()}
name, rank := m.resolveTier(productPrice("price_supporter_v1_retired", "prod_supporter"))
if name != "supporter" || rank != 1 {
t.Errorf("resolveTier(retired price, live product) = (%q, %d), want (\"supporter\", 1) — "+
"a subscriber on the old price lost their tier", name, rank)
}
}
// TestResolveTier_FallsBackToPriceWhenNoProductConfigured: a config written
// before stripe_product existed must keep resolving exactly as it did.
func TestResolveTier_FallsBackToPriceWhenNoProductConfigured(t *testing.T) {
m := &Manager{cfg: tierTestConfig()}
name, rank := m.resolveTier(productPrice("price_pro_only", "prod_pro_unconfigured"))
if name != "pro" || rank != 2 {
t.Errorf("resolveTier = (%q, %d), want (\"pro\", 2) — the price fallback stopped working", name, rank)
}
}
// TestResolveTier_ProductWinsOverPrice: when both could match, the product
// decides. Otherwise a price ID left behind on the wrong tier could outvote the
// product and grant the wrong entitlement.
func TestResolveTier_ProductWinsOverPrice(t *testing.T) {
m := &Manager{cfg: &Config{Tiers: []BillingTierConfig{
{Name: "free"},
{Name: "supporter", StripeProduct: "prod_supporter"},
{Name: "pro", StripePriceMonthly: "price_shared"},
}}}
name, _ := m.resolveTier(productPrice("price_shared", "prod_supporter"))
if name != "supporter" {
t.Errorf("resolveTier = %q, want \"supporter\" — the price ID outvoted the product", name)
}
}
// TestResolveTier_UnknownEverythingIsUnresolved keeps the negative honest: a
// price on a product the config has never heard of must not resolve to a tier.
func TestResolveTier_UnknownEverythingIsUnresolved(t *testing.T) {
m := &Manager{cfg: tierTestConfig()}
if name, rank := m.resolveTier(productPrice("price_unknown", "prod_unknown")); name != "" || rank != -1 {
t.Errorf("resolveTier(unknown) = (%q, %d), want (\"\", -1)", name, rank)
}
if name, rank := m.resolveTier(nil); name != "" || rank != -1 {
t.Errorf("resolveTier(nil) = (%q, %d), want (\"\", -1)", name, rank)
}
}
// managedHoldServer starts an httptest server standing in for a managed hold
// and returns it with the did:web the tier fan-out resolves back to it. The
// server serves its own DID document at /.well-known/did.json; handler gets
// every other request. Resolving a loopback did:web needs a `-tags testmode`
// build, so the test fails fast with that message otherwise.
func managedHoldServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, string) {
t.Helper()
if !atproto.TestModeBuild {
t.Fatal("this test resolves a did:web on 127.0.0.1 and needs a `-tags testmode` build")
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
testpds.HoldDIDDocumentHandler(testpds.DIDWebForURL(base), base)(w, r)
})
mux.Handle("/", handler)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, testpds.DIDWebForURL(srv.URL)
}
// TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier runs the same
// property through the real webhook path, so it covers the payload shape too:
// Stripe sends price.product as a bare ID string, and this fails if that is
// ever read wrongly.
//
// It asserts on the rank the managed hold is actually asked to apply. An
// earlier draft asserted that the event was recorded as processed, and passed
// against price-only resolution — because an unresolved tier ALSO records the
// event and returns nil. That is the defect itself, so any assertion it
// satisfies cannot be measuring the fix.
func TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier(t *testing.T) {
atproto.SetTestMode(true)
t.Cleanup(func() { atproto.SetTestMode(false) })
const secret = "whsec_grandfather_test"
m, _ := newTestManager(t, secret)
m.cfg.Tiers = tierTestConfig().Tiers
stripeAPIReturning(t, http.StatusOK,
`{"id":"cus_gf","object":"customer","metadata":{"user_did":"did:plc:grandfathered"}}`)
type tierPush struct {
UserDID string `json:"userDid"`
TierRank int `json:"tierRank"`
}
pushes := make(chan tierPush, 4)
_, holdDID := managedHoldServer(t, func(w http.ResponseWriter, r *http.Request) {
var got tierPush
_ = json.NewDecoder(r.Body).Decode(&got)
pushes <- got
w.WriteHeader(http.StatusOK)
})
m.managedHolds = []string{holdDID}
priv, err := atcrypto.GeneratePrivateKeyP256()
if err != nil {
t.Fatalf("generate key: %v", err)
}
m.privateKey = priv
// price_supporter_v1_retired appears nowhere in the config. Only its
// product does — this is the subscriber a price change left behind.
payload := fmt.Sprintf(`{
"id": "evt_grandfathered",
"object": "event",
"api_version": %q,
"type": "customer.subscription.updated",
"created": %d,
"data": {"object": {
"id": "sub_gf",
"object": "subscription",
"status": "active",
"customer": "cus_gf",
"items": {"object":"list","data":[{"price":{
"id":"price_supporter_v1_retired",
"product":"prod_supporter"
}}]}
}}
}`, stripe.APIVersion, time.Now().Unix())
if err := postWebhook(t, m, secret, []byte(payload)); err != nil {
t.Fatalf("webhook: %v", err)
}
select {
case got := <-pushes:
if got.TierRank != 1 {
t.Errorf("hold was asked for tierRank %d, want 1 (supporter)", got.TierRank)
}
if got.UserDID != "did:plc:grandfathered" {
t.Errorf("hold was asked to update %q, want did:plc:grandfathered", got.UserDID)
}
case <-time.After(2 * time.Second):
t.Fatal("no tier push reached the hold — the grandfathered subscriber's tier was never applied")
}
}