mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-29 21:45:33 +00:00
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
200 lines
7.0 KiB
Go
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")
|
|
}
|
|
}
|