mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
3201342d3d
* feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half)
pad-cloud marked a webhook event processed BEFORE its handler ran, and the
mark was final. A sidecar crash, OOM or redeploy mid-handler, or a failed
unmark, left the event 'processed' forever: every Stripe retry was
acknowledged as a duplicate. On an annual plan cancelled early, the user kept
Pro until the period end.
The marker now has two states (migration 136 / PG 108; existing rows read
as 'done', which is what they meant):
- ClaimStripeEvent inserts a new event 'processing' with a 5-minute lease
(claimed). A 'done' event is a duplicate. A 'processing' event with a
live lease is in_progress. One with an EXPIRED lease is taken over by one
conditional UPDATE with a new token: of several racing retries exactly
one wins.
- POST /api/v1/admin/stripe-event-complete {event_id, processed_at} marks it
done after the handler succeeds. It is token-scoped, so a handler whose
lease was taken over cannot complete it for the one that took it.
- POST /admin/stripe-event-processed keeps already_processed with its old
meaning (anything but a fresh claim) and adds 'state', which a newer
sidecar feature-detects (lead ruling). A sidecar that predates this never
completes; its rows stay 'processing', and a redelivery after the lease
re-runs a handler that re-syncs from current Stripe state, which is
harmless.
Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, BUG-3361's cloud-only route list, and the cloud contract
goldens (processed gains 'state'; complete is new). The NUL census records
the two new server-written columns. Test: claim, in-progress, done, an
expired lease taken over exactly once by 4 racing claimers, and a
stale-token complete as a no-op. Without the takeover, 0 of 4 claim.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(cloud): the contract test gets a roomy cloud-admin limiter (BUG-3561)
It calls every sidecar route once from one address to pin response shapes,
and with billing-users and stripe-event-complete the list outgrew the real
limiter's burst of 10 (the 11th call answered 429). Rate limiting is not what
it tests.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
209 lines
7.8 KiB
Go
209 lines
7.8 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// The responses the pad-cloud sidecar DECODES, captured from the real handlers
|
|
// as golden shapes. pad-cloud keeps a copy (testdata/pad_contract/) and decodes
|
|
// each through its own client, so a key renamed or dropped on one side of the
|
|
// repo boundary fails a test instead of shipping. The class this closes:
|
|
// user-by-customer answered "user_id" while pad-cloud read "id", so every
|
|
// subscription webhook synced user "" for months.
|
|
//
|
|
// A changed shape is a contract change: review the diff of
|
|
// testdata/cloud_contract/, then copy the files to pad-cloud's
|
|
// testdata/pad_contract/. Regenerate with
|
|
//
|
|
// PAD_UPDATE_CLOUD_CONTRACT=1 go test ./internal/server -run TestCloudContract
|
|
//
|
|
// Values are replaced by placeholders of their JSON type ("<string>", 0,
|
|
// booleans as they are), so the files hold the SHAPE, not one test run's ids.
|
|
// The exception is a "code" value: an error code is vocabulary the sidecar
|
|
// branches on (stripe_customer_conflict, TASK-3549), so it is the contract
|
|
// and is kept verbatim.
|
|
|
|
const cloudContractSecret = "cloud-contract-secret"
|
|
|
|
func contractShape(v any) any {
|
|
switch x := v.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(x))
|
|
for k, vv := range x {
|
|
if code, isString := vv.(string); k == "code" && isString {
|
|
out[k] = code
|
|
continue
|
|
}
|
|
out[k] = contractShape(vv)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(x))
|
|
for i, vv := range x {
|
|
out[i] = contractShape(vv)
|
|
}
|
|
return out
|
|
case string:
|
|
if x == "" {
|
|
return ""
|
|
}
|
|
return "<string>"
|
|
case float64:
|
|
return 0
|
|
default:
|
|
return x
|
|
}
|
|
}
|
|
|
|
func TestCloudContract(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode(cloudContractSecret)
|
|
// This test pins response SHAPES and calls every sidecar route once, from
|
|
// one address. The real cloud-admin limiter (burst 10) is not what it
|
|
// tests, and the route list outgrew it (BUG-3561), so it gets a roomy one.
|
|
srv.rateLimiters.CloudAdmin.Stop()
|
|
srv.rateLimiters.CloudAdmin = newIPRateLimiter(rateLimitConfig{Rate: rate.Limit(100), Burst: 100})
|
|
t.Cleanup(srv.rateLimiters.CloudAdmin.Stop)
|
|
u, err := srv.store.CreateUser(models.UserCreate{Email: "buyer@example.com", Name: "Buyer", Password: "pw-contract-x"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.store.SetUserStripeCustomerID(u.ID, "cus_contract"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hdr := map[string]string{"X-Cloud-Secret": cloudContractSecret}
|
|
callCode := func(code int, method, path string, body any) map[string]any {
|
|
t.Helper()
|
|
req := cloudAdminReq(t, method, path, body, hdr)
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != code {
|
|
t.Fatalf("%s %s: %d %s", method, path, rr.Code, rr.Body.String())
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
return out
|
|
}
|
|
call := func(method, path string, body any) map[string]any {
|
|
t.Helper()
|
|
return callCode(http.StatusOK, method, path, body)
|
|
}
|
|
|
|
shapes := map[string]map[string]any{
|
|
// Checkout reads the stored customer by user before it writes one, and
|
|
// the write is compare-and-set: the same id answers 200, a different
|
|
// one 409 naming the stored id, which pad-cloud then uses (TASK-3549).
|
|
"admin_user_by_id": call("GET", "/api/v1/admin/user-by-id?user_id="+u.ID, nil),
|
|
"admin_stripe_customer_id": call("POST", "/api/v1/admin/stripe-customer-id", map[string]any{
|
|
"user_id": u.ID, "customer_id": "cus_contract", "cloud_secret": cloudContractSecret,
|
|
}),
|
|
"admin_stripe_customer_id_conflict": callCode(http.StatusConflict, "POST", "/api/v1/admin/stripe-customer-id", map[string]any{
|
|
"user_id": u.ID, "customer_id": "cus_contract_other", "cloud_secret": cloudContractSecret,
|
|
}),
|
|
"admin_user_by_customer": call("GET", "/api/v1/admin/user-by-customer?customer_id=cus_contract", nil),
|
|
// The reconcile sweep pages billing users (TASK-3564).
|
|
"admin_billing_users": call("GET", "/api/v1/admin/billing-users?limit=10", nil),
|
|
"admin_plan": call("POST", "/api/v1/admin/plan", map[string]any{
|
|
"user_id": u.ID, "plan": "pro", "expires_at": "2099-01-01T00:00:00Z", "source": "stripe",
|
|
"revision": 1, "subscription_id": "sub_contract", "cloud_secret": cloudContractSecret,
|
|
}),
|
|
"admin_stripe_event_processed": call("POST", "/api/v1/admin/stripe-event-processed", map[string]any{
|
|
"event_id": "evt_contract", "cloud_secret": cloudContractSecret,
|
|
}),
|
|
"admin_payment_failed": call("POST", "/api/v1/admin/payment-failed", map[string]any{
|
|
"stripe_customer_id": "cus_contract", "amount_display": "$12.00", "next_retry_display": "in 3 days",
|
|
"cloud_secret": cloudContractSecret,
|
|
}),
|
|
"auth_oauth_login": call("POST", "/api/v1/auth/oauth-login", map[string]any{
|
|
"provider": "github", "email": "oauth@example.com", "name": "OAuth", "email_verified": true,
|
|
"subject": "gh-contract", "cloud_secret": cloudContractSecret,
|
|
}),
|
|
}
|
|
processed := shapes["admin_stripe_event_processed"]
|
|
unmarkAt, _ := processed["processed_at"].(string)
|
|
// BUG-3561: the claim is completed after the handler succeeds. A second
|
|
// event is claimed for that, since unmark below removes the first.
|
|
claimed := call("POST", "/api/v1/admin/stripe-event-processed", map[string]any{
|
|
"event_id": "evt_contract_complete", "cloud_secret": cloudContractSecret,
|
|
})
|
|
claimedAt, _ := claimed["processed_at"].(string)
|
|
shapes["admin_stripe_event_complete"] = call("POST", "/api/v1/admin/stripe-event-complete", map[string]any{
|
|
"event_id": "evt_contract_complete", "processed_at": claimedAt, "cloud_secret": cloudContractSecret,
|
|
})
|
|
shapes["admin_stripe_event_unmark"] = call("POST", "/api/v1/admin/stripe-event-unmark", map[string]any{
|
|
"event_id": "evt_contract", "processed_at": unmarkAt, "cloud_secret": cloudContractSecret,
|
|
})
|
|
|
|
// /auth/me is how pad-cloud validates a browser session. Its checkout
|
|
// reads stripe_customer_id from it, which pad sends only when set.
|
|
token, _ := shapes["auth_oauth_login"]["token"].(string)
|
|
oauthUser, err := srv.store.GetUserByEmail("oauth@example.com")
|
|
if err != nil || oauthUser == nil {
|
|
t.Fatalf("oauth user: %v", err)
|
|
}
|
|
if err := srv.store.SetUserStripeCustomerID(oauthUser.ID, "cus_contract_me"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest("GET", "/api/v1/auth/me", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName(srv.secureCookies), Value: token})
|
|
req.RemoteAddr = "192.0.2.1:1"
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("auth/me: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var me map[string]any
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &me)
|
|
shapes["auth_me"] = me
|
|
|
|
names := make([]string, 0, len(shapes))
|
|
for n := range shapes {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
dir := filepath.Join("testdata", "cloud_contract")
|
|
update := os.Getenv("PAD_UPDATE_CLOUD_CONTRACT") == "1"
|
|
if update {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, n := range names {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(contractShape(shapes[n])); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := buf.Bytes()
|
|
path := filepath.Join(dir, n+".json")
|
|
if update {
|
|
if err := os.WriteFile(path, got, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
continue
|
|
}
|
|
want, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v (regenerate with PAD_UPDATE_CLOUD_CONTRACT=1)", path, err)
|
|
}
|
|
if string(want) != string(got) {
|
|
t.Errorf("%s changed: a response pad-cloud decodes has a new shape. Review it, regenerate, and copy it to pad-cloud's testdata/pad_contract/.\n got:\n%s\nwant:\n%s", path, got, want)
|
|
}
|
|
}
|
|
}
|