mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
d95f90850c
* fix(store): a role change or disable of a vanished user is 404, and the unguarded DisableUser is gone (BUG-3826) Follow-ups to BUG-3811's last-active-admin guard, one unit per the lead: 1. Store.DisableUser wrote disabled_at with no last-admin guard and no lock, the one admin-removing writer outside the invariant. Removed. Its test callers need the pre-BUG-3349 state (disabled, credentials not revoked), so they move to DisableUserLeavingCredentialsForTesting. 2. A user deleted between the handler's read and the write: a promotion answered ErrLastAdmin (a false 409 last_active_admin), a demotion a plain error (500), and a disable succeeded on nothing (200). All three now return store.ErrUserNotFound, which both handlers answer 404. The disable takes the users row FOR NO KEY UPDATE after the guard, so a concurrent deletion cannot slip between the check and the UPDATE. 3. The race test only held demote or disable open as the first door. Account deletion is now held at its user-row lock too, so moving its guard after that lock goes red. 4. docs/api.md documents 409 last_active_admin on delete-account, the admin role PATCH and disable, and the new 404. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * docs(api): the vanished-user 404 is promised for a role change only (BUG-3826) Codex r1 (Low): the plan fields of the same PATCH do not map ErrUserNotFound. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * docs,test: the delete-account 409 is checked before the Stripe cancel and re-checked after; the race test comment names its contrast (BUG-3826) Blind hunter r2 (Lows): the docs said the refusal comes before any Stripe cancel, which is true of the pre-check only; the handler answers 500 partial_delete when the locked re-check refuses after a cancel ran. The race test's comment read as if it described the other test. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
1089 lines
42 KiB
Go
1089 lines
42 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/email"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// cloudAdminReq is a small helper that builds a request with optional JSON body
|
|
// and the caller-chosen headers.
|
|
func cloudAdminReq(t *testing.T, method, path string, body interface{}, headers map[string]string) *http.Request {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
r = bytes.NewReader(b)
|
|
}
|
|
req := httptest.NewRequest(method, path, r)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.RemoteAddr = "192.0.2.1:1"
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
return req
|
|
}
|
|
|
|
// TestCloudAdminGate_SelfHost_Returns404 verifies the cloud-admin endpoints
|
|
// disappear entirely when the server isn't in cloud mode — no "cloud mode
|
|
// not configured" disclosure, no auth prompt, just 404.
|
|
func TestCloudAdminGate_SelfHost_Returns404(t *testing.T) {
|
|
srv := testServer(t)
|
|
// Not in cloud mode — SetCloudMode never called.
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body interface{}
|
|
}{
|
|
{"POST /admin/plan with cloud secret header", "POST", "/api/v1/admin/plan", map[string]string{"cloud_secret": "x"}},
|
|
{"POST /admin/stripe-customer-id with header", "POST", "/api/v1/admin/stripe-customer-id", map[string]string{"cloud_secret": "x"}},
|
|
{"GET /admin/user-by-customer with header", "GET", "/api/v1/admin/user-by-customer?customer_id=cus_x", nil},
|
|
{"GET /admin/user-by-id with header", "GET", "/api/v1/admin/user-by-id?user_id=u", nil},
|
|
{"POST /admin/stripe-event-processed with header", "POST", "/api/v1/admin/stripe-event-processed", map[string]string{"cloud_secret": "x", "event_id": "evt_x"}},
|
|
{"POST /admin/stripe-event-unmark with header", "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{"cloud_secret": "x", "event_id": "evt_x", "processed_at": "2025-01-01T00:00:00Z"}},
|
|
{"POST /admin/payment-failed with header", "POST", "/api/v1/admin/payment-failed", map[string]string{"cloud_secret": "x", "stripe_customer_id": "cus_x"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Attach X-Cloud-Secret so the auth gate lets us through and we
|
|
// hit requireCloudMode specifically.
|
|
req := cloudAdminReq(t, tt.method, tt.path, tt.body, map[string]string{"X-Cloud-Secret": "any-value"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("%s: expected 404 in self-host mode, got %d: %s", tt.name, rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_NoCloudSecret_RequiresAuth verifies that without
|
|
// X-Cloud-Secret/?cloud_secret the endpoint falls through to the normal
|
|
// auth gate — an anonymous probe gets 401, not the handler-level
|
|
// "Cloud mode not configured" response that used to leak.
|
|
func TestCloudAdminGate_NoCloudSecret_RequiresAuth(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("secret-for-cloud") // enable cloud mode so requireCloudMode doesn't 404
|
|
|
|
req := cloudAdminReq(t, "GET", "/api/v1/admin/user-by-customer?customer_id=cus_x", nil, nil)
|
|
// No X-Cloud-Secret, no cookie, no token.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 (auth required), got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_ValidCloudSecret_PassesAuthAndCSRF verifies a sidecar
|
|
// POST with the right X-Cloud-Secret reaches the handler — CSRF is off
|
|
// because the header signals non-cookie auth.
|
|
func TestCloudAdminGate_ValidCloudSecret_PassesAuthAndCSRF(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-customer-id", map[string]string{
|
|
"user_id": "does-not-exist",
|
|
"customer_id": "cus_1234",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
// User doesn't exist so we expect 404 from the handler — but critically,
|
|
// NOT 401/403 from auth/CSRF middleware. Reaching the handler at all is
|
|
// the proof that the gate let the sidecar through.
|
|
if rr.Code == http.StatusUnauthorized || rr.Code == http.StatusForbidden {
|
|
t.Fatalf("auth/CSRF incorrectly blocked sidecar call: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("expected handler to reject unknown user_id with 404, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_BypassScopedToCloudPaths verifies the regression
|
|
// Codex P0'd on PR #182: setting X-Cloud-Secret on a NON-cloud-admin
|
|
// path must NOT bypass auth. Applies to every non-whitelisted route.
|
|
func TestCloudAdminGate_BypassScopedToCloudPaths(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
|
|
// Setting X-Cloud-Secret on GET /api/v1/workspaces must still require
|
|
// normal user auth — a pre-fix attacker could list workspaces anonymously.
|
|
req := cloudAdminReq(t, "GET", "/api/v1/workspaces", nil,
|
|
map[string]string{"X-Cloud-Secret": "does-not-matter"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("X-Cloud-Secret on non-cloud path bypassed auth: got %d, want 401", rr.Code)
|
|
}
|
|
|
|
// Same test with the legacy query-param.
|
|
req = cloudAdminReq(t, "GET", "/api/v1/workspaces?cloud_secret=x", nil, nil)
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("?cloud_secret on non-cloud path bypassed auth: got %d, want 401", rr.Code)
|
|
}
|
|
|
|
// POST /api/v1/workspaces — creates a workspace. If X-Cloud-Secret
|
|
// bypassed auth here, an anon attacker could create workspaces.
|
|
// CSRF middleware may reject first (403) before auth (401); either
|
|
// status is a valid rejection, but must NOT be a 2xx.
|
|
req = cloudAdminReq(t, "POST", "/api/v1/workspaces",
|
|
map[string]string{"name": "hijacked"},
|
|
map[string]string{"X-Cloud-Secret": "x"})
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code < 400 {
|
|
t.Fatalf("X-Cloud-Secret on POST /workspaces bypassed auth: got %d (expected 401 or 403)", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_BodySecret_BackwardCompat verifies a POST with
|
|
// cloud_secret ONLY in the JSON body still works — the existing pad-cloud
|
|
// sidecar sent the secret there, not in a header, so removing body support
|
|
// outright would break deployed sidecars. Scoped to cloud admin paths
|
|
// only (the body peek never fires elsewhere).
|
|
func TestCloudAdminGate_BodySecret_BackwardCompat(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("body-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-customer-id", map[string]string{
|
|
"user_id": "unknown-user-id",
|
|
"customer_id": "cus_body",
|
|
"cloud_secret": "body-secret",
|
|
}, nil) // NO X-Cloud-Secret header — body only
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
// Must reach the handler — not be rejected at auth/CSRF.
|
|
if rr.Code == http.StatusUnauthorized || rr.Code == http.StatusForbidden {
|
|
t.Fatalf("body-only cloud_secret rejected by middleware: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_QueryParamSecret_Rejected verifies TASK-656 dropped
|
|
// the legacy ?cloud_secret= query-param — query values land in access
|
|
// logs, so accepting them there leaked the cloud trust boundary. Must
|
|
// be rejected by the auth gate.
|
|
func TestCloudAdminGate_QueryParamSecret_Rejected(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("legacy-secret")
|
|
|
|
req := cloudAdminReq(t, "GET",
|
|
"/api/v1/admin/user-by-customer?customer_id=cus_unknown&cloud_secret=legacy-secret",
|
|
nil, nil)
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("?cloud_secret= should no longer authenticate, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestStripeEventProcessed_RecordsAndDetectsDuplicates verifies TASK-696:
|
|
// first call for a given event_id returns already_processed=false; a
|
|
// second call for the same event_id returns already_processed=true. This
|
|
// is what gives the sidecar durable idempotency across restarts.
|
|
//
|
|
// Also covers TASK-736: the response MUST include processed_at, and the
|
|
// duplicate call MUST return the existing row's processed_at (not a fresh
|
|
// one) so the sidecar can store a token that matches the row in the DB.
|
|
func TestStripeEventProcessed_RecordsAndDetectsDuplicates(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
// First call — should be new.
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-processed", map[string]string{
|
|
"event_id": "evt_test_12345",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("first call expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var first struct {
|
|
EventID string `json:"event_id"`
|
|
AlreadyProcessed bool `json:"already_processed"`
|
|
ProcessedAt string `json:"processed_at"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &first); err != nil {
|
|
t.Fatalf("decode first response: %v", err)
|
|
}
|
|
if first.AlreadyProcessed {
|
|
t.Fatalf("first call should return already_processed=false")
|
|
}
|
|
if first.EventID != "evt_test_12345" {
|
|
t.Fatalf("first call returned wrong event_id: %q", first.EventID)
|
|
}
|
|
if first.ProcessedAt == "" {
|
|
t.Fatalf("first call must return a non-empty processed_at for the unmark round-trip")
|
|
}
|
|
|
|
// Second call with same event_id — must be flagged as duplicate AND
|
|
// return the existing row's processed_at (not a fresh timestamp).
|
|
req = cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-processed", map[string]string{
|
|
"event_id": "evt_test_12345",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("second call expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var second struct {
|
|
EventID string `json:"event_id"`
|
|
AlreadyProcessed bool `json:"already_processed"`
|
|
ProcessedAt string `json:"processed_at"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &second); err != nil {
|
|
t.Fatalf("decode second response: %v", err)
|
|
}
|
|
if !second.AlreadyProcessed {
|
|
t.Fatalf("second call should return already_processed=true")
|
|
}
|
|
if second.ProcessedAt != first.ProcessedAt {
|
|
t.Errorf("duplicate call must return the EXISTING row's processed_at (%q), got %q",
|
|
first.ProcessedAt, second.ProcessedAt)
|
|
}
|
|
}
|
|
|
|
// markEventForUnmarkTest is a helper that POSTs stripe-event-processed and
|
|
// returns the processed_at token the response hands back. Keeps the setup
|
|
// of the unmark tests focused on the unmark semantics.
|
|
func markEventForUnmarkTest(t *testing.T, srv *Server, eventID, secret string) string {
|
|
t.Helper()
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-processed", map[string]string{
|
|
"event_id": eventID,
|
|
"cloud_secret": secret,
|
|
}, map[string]string{"X-Cloud-Secret": secret})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("mark %s: %d %s", eventID, rr.Code, rr.Body.String())
|
|
}
|
|
var resp struct {
|
|
ProcessedAt string `json:"processed_at"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode mark response: %v", err)
|
|
}
|
|
if resp.ProcessedAt == "" {
|
|
t.Fatalf("mark response missing processed_at")
|
|
}
|
|
return resp.ProcessedAt
|
|
}
|
|
|
|
// TestStripeEventUnmark_RoundTripWithMarkProcessed is the happy-path
|
|
// regression for TASK-736: a row previously written by /stripe-event-processed
|
|
// can be deleted by the unmark endpoint (given the matching processed_at
|
|
// token), and a subsequent mark call returns already_processed=false
|
|
// (proving the row really went away and Stripe retries can re-run).
|
|
func TestStripeEventUnmark_RoundTripWithMarkProcessed(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
// 1. Mark an event as processed and grab the token.
|
|
token := markEventForUnmarkTest(t, srv, "evt_unmark_roundtrip", "shh-its-a-secret")
|
|
|
|
// 2. Unmark it — should report unmarked=true.
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_unmark_roundtrip",
|
|
"processed_at": token,
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("unmark: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var unmarkResp struct {
|
|
EventID string `json:"event_id"`
|
|
Unmarked bool `json:"unmarked"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &unmarkResp); err != nil {
|
|
t.Fatalf("decode unmark: %v", err)
|
|
}
|
|
if !unmarkResp.Unmarked {
|
|
t.Errorf("unmark should return unmarked=true when row existed")
|
|
}
|
|
if unmarkResp.EventID != "evt_unmark_roundtrip" {
|
|
t.Errorf("unmark returned wrong event_id: %q", unmarkResp.EventID)
|
|
}
|
|
|
|
// 3. Re-mark — now that the row is gone, must report already_processed=false.
|
|
req = cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-processed", map[string]string{
|
|
"event_id": "evt_unmark_roundtrip",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("re-mark: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var remark struct {
|
|
AlreadyProcessed bool `json:"already_processed"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &remark); err != nil {
|
|
t.Fatalf("decode re-mark: %v", err)
|
|
}
|
|
if remark.AlreadyProcessed {
|
|
t.Errorf("after unmark, re-mark must return already_processed=false (retry path broken)")
|
|
}
|
|
}
|
|
|
|
// TestStripeEventUnmark_StaleTokenIsNoOp addresses the TASK-736 race-
|
|
// protection HIGH: a delayed unmark from an EARLIER failed attempt must
|
|
// NOT delete the fresh marker left by a SUCCESSFUL retry. The composite
|
|
// (event_id, processed_at) delete key enforces this — a stale token
|
|
// simply doesn't match the fresh row.
|
|
func TestStripeEventUnmark_StaleTokenIsNoOp(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
// Capture the stale token from the first (doomed) attempt.
|
|
staleTok := markEventForUnmarkTest(t, srv, "evt_race", "shh-its-a-secret")
|
|
|
|
// Sidecar rolls back (successful unmark with the stale token).
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_race",
|
|
"processed_at": staleTok,
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("rollback: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// now() has second resolution; wait so the next mark writes a
|
|
// distinct timestamp. Without this the retry could re-use the same
|
|
// token and the test assumption collapses.
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
// A successful retry re-marks with a fresh token.
|
|
freshTok := markEventForUnmarkTest(t, srv, "evt_race", "shh-its-a-secret")
|
|
if freshTok == staleTok {
|
|
t.Fatalf("fresh mark reused the stale token (%q) — test setup assumption broken", freshTok)
|
|
}
|
|
|
|
// A delayed stale unmark fires. MUST be a no-op so the fresh row
|
|
// stays put.
|
|
req = cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_race",
|
|
"processed_at": staleTok,
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("stale unmark: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp struct {
|
|
Unmarked bool `json:"unmarked"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode stale unmark: %v", err)
|
|
}
|
|
if resp.Unmarked {
|
|
t.Error("stale unmark must NOT delete the fresh marker (race would reopen retry window)")
|
|
}
|
|
|
|
// Sanity check: marking again still sees the fresh row.
|
|
req = cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-processed", map[string]string{
|
|
"event_id": "evt_race",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
var sanity struct {
|
|
AlreadyProcessed bool `json:"already_processed"`
|
|
ProcessedAt string `json:"processed_at"`
|
|
}
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &sanity)
|
|
if !sanity.AlreadyProcessed {
|
|
t.Error("fresh marker must still be present after stale unmark")
|
|
}
|
|
if sanity.ProcessedAt != freshTok {
|
|
t.Errorf("token changed unexpectedly; got %q, want %q", sanity.ProcessedAt, freshTok)
|
|
}
|
|
}
|
|
|
|
// TestStripeEventUnmark_IdempotentWhenRowMissing verifies the unmark call
|
|
// succeeds with unmarked=false when the event ID was never marked. Either
|
|
// outcome is a 200.
|
|
func TestStripeEventUnmark_IdempotentWhenRowMissing(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_never_marked",
|
|
"processed_at": "2025-01-01T00:00:00Z",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 on missing-row unmark, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp struct {
|
|
Unmarked bool `json:"unmarked"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Unmarked {
|
|
t.Errorf("unmark of missing row must return unmarked=false")
|
|
}
|
|
}
|
|
|
|
// TestStripeEventUnmark_RejectsWrongSecret verifies the cloud-secret gate
|
|
// is applied symmetrically with /stripe-event-processed.
|
|
func TestStripeEventUnmark_RejectsWrongSecret(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("the-real-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_x",
|
|
"processed_at": "2025-01-01T00:00:00Z",
|
|
"cloud_secret": "wrong-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "wrong-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 on wrong secret, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestStripeEventUnmark_RequiresProcessedAt verifies that a missing
|
|
// processed_at field produces a 400 rather than falling back to any
|
|
// fallback-by-event-id behaviour. The processed_at is the race-protection
|
|
// contract; dropping it would be unsafe.
|
|
func TestStripeEventUnmark_RequiresProcessedAt(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_x",
|
|
// processed_at intentionally omitted
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 without processed_at, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestStripeEventUnmark_ValidatesEventIDPrefix verifies the handler
|
|
// rejects event IDs that don't start with 'evt_'.
|
|
func TestStripeEventUnmark_ValidatesEventIDPrefix(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
tests := []struct {
|
|
name string
|
|
eventID string
|
|
}{
|
|
{"empty event_id", ""},
|
|
{"missing evt_ prefix", "sub_12345"},
|
|
{"wrong prefix cus_", "cus_12345"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": tt.eventID,
|
|
"processed_at": "2025-01-01T00:00:00Z",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400, got %d: %s", tt.name, rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStripeEventUnmark_WritesAuditLog is the TASK-736 durable-audit
|
|
// finding fix: a successful unmark MUST emit an ActionStripeEventUnmarked
|
|
// audit entry so admins can see who reopened retry windows and when. slog
|
|
// alone is not enough (no actor, not queryable via /audit-log).
|
|
func TestStripeEventUnmark_WritesAuditLog(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
token := markEventForUnmarkTest(t, srv, "evt_audit", "shh-its-a-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{
|
|
"event_id": "evt_audit",
|
|
"processed_at": token,
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("unmark: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Verify an audit row exists via the audit-log query interface.
|
|
activities, err := srv.store.ListAuditLog(models.AuditLogParams{Action: models.ActionStripeEventUnmarked, Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("list audit log: %v", err)
|
|
}
|
|
if len(activities) == 0 {
|
|
t.Fatal("no ActionStripeEventUnmarked activity was logged")
|
|
}
|
|
// Find the one we just created.
|
|
var found bool
|
|
for _, a := range activities {
|
|
if strings.Contains(a.Metadata, "evt_audit") {
|
|
found = true
|
|
// Metadata should record unmarked=true.
|
|
if !strings.Contains(a.Metadata, `"unmarked":"true"`) {
|
|
t.Errorf("expected unmarked=true in metadata, got %q", a.Metadata)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no audit entry contained event_id=evt_audit; got %d entries", len(activities))
|
|
}
|
|
}
|
|
|
|
// TestStripeEventProcessed_ValidatesEventIDPrefix verifies the handler
|
|
// rejects event IDs that don't start with 'evt_', matching the existing
|
|
// 'cus_' prefix validation on stripe-customer-id.
|
|
func TestStripeEventProcessed_ValidatesEventIDPrefix(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
tests := []struct {
|
|
name string
|
|
eventID string
|
|
}{
|
|
{"empty event_id", ""},
|
|
{"missing evt_ prefix", "sub_12345"},
|
|
{"wrong prefix cus_", "cus_12345"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/stripe-event-processed", map[string]string{
|
|
"event_id": tt.eventID,
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400, got %d: %s", tt.name, rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPaymentFailed_ValidatesCustomerIDPrefix rejects stripe_customer_id
|
|
// values that don't start with 'cus_'. Same shape as the evt_ prefix
|
|
// check on stripe-event-processed.
|
|
func TestPaymentFailed_ValidatesCustomerIDPrefix(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
tests := []struct {
|
|
name string
|
|
customerID string
|
|
}{
|
|
{"empty customer_id", ""},
|
|
{"missing cus_ prefix", "sub_12345"},
|
|
{"wrong prefix evt_", "evt_12345"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{
|
|
"stripe_customer_id": tt.customerID,
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400, got %d: %s", tt.name, rr.Code, rr.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPaymentFailed_UnknownCustomer_Returns200_NoEmail verifies that when
|
|
// the sidecar forwards an invoice.payment_failed for a customer pad does
|
|
// not recognise, pad returns 200 with email_sent=false and reason=
|
|
// "no_customer" — the sidecar must NOT treat this as a 5xx that would
|
|
// trigger a webhook retry.
|
|
func TestPaymentFailed_UnknownCustomer_Returns200_NoEmail(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{
|
|
"stripe_customer_id": "cus_nonexistent_abc",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for unknown customer, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if sent, _ := resp["email_sent"].(bool); sent {
|
|
t.Errorf("email_sent=true for unknown customer; want false")
|
|
}
|
|
if got, _ := resp["reason"].(string); got != "no_customer" {
|
|
t.Errorf("reason=%q for unknown customer; want no_customer", got)
|
|
}
|
|
}
|
|
|
|
// TestPaymentFailed_EmailNotConfigured_Returns200_NoEmail verifies the
|
|
// handler degrades gracefully when Maileroo is not wired up — audit log
|
|
// captures the skip, response is 200 so the sidecar doesn't retry.
|
|
func TestPaymentFailed_EmailNotConfigured_Returns200_NoEmail(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
// Look up the bootstrapped admin and assign a Stripe customer ID so
|
|
// the handler's lookup step resolves, then falls through to the
|
|
// "email not configured" branch.
|
|
adminUser, err := srv.store.GetUserByEmail("admin@example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetUserByEmail: %v", err)
|
|
}
|
|
if err := srv.store.SetUserStripeCustomerID(adminUser.ID, "cus_test_nomail"); err != nil {
|
|
t.Fatalf("SetUserStripeCustomerID: %v", err)
|
|
}
|
|
// Note: testServer does NOT call email.Configure, so s.email has no
|
|
// credentials and s.baseURL is empty — both fail the configured check.
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{
|
|
"stripe_customer_id": "cus_test_nomail",
|
|
"amount_display": "$10.00",
|
|
"next_retry_display": "April 30, 2026",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 when email not configured, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if sent, _ := resp["email_sent"].(bool); sent {
|
|
t.Errorf("email_sent=true with no Maileroo config; want false")
|
|
}
|
|
if got, _ := resp["reason"].(string); got != "email_not_configured" {
|
|
t.Errorf("reason=%q with no Maileroo config; want email_not_configured", got)
|
|
}
|
|
}
|
|
|
|
// paymentFailedAuditForUser walks the audit log for a given user and
|
|
// returns every payment_failed_email_sent row's parsed metadata. Used
|
|
// by the send-path tests to assert reason + sent fields land correctly
|
|
// for each branch.
|
|
func paymentFailedAuditForUser(t *testing.T, srv *Server, userID string) []map[string]string {
|
|
t.Helper()
|
|
events, err := srv.store.ListAuditLog(models.AuditLogParams{
|
|
Action: models.ActionPaymentFailedEmailSent,
|
|
Limit: 100,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListAuditLog: %v", err)
|
|
}
|
|
var out []map[string]string
|
|
for _, ev := range events {
|
|
if userID != "" && ev.UserID != userID {
|
|
continue
|
|
}
|
|
var meta map[string]string
|
|
_ = json.Unmarshal([]byte(ev.Metadata), &meta)
|
|
out = append(out, meta)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mockMailerooEndpoint stands up an httptest server that speaks enough
|
|
// of Maileroo's v2 JSON API to satisfy email.Sender. Returning 500
|
|
// exercises the send_failed branch; returning 200 + success:true
|
|
// exercises the sent branch.
|
|
func mockMailerooEndpoint(t *testing.T, status int, success bool) *httptest.Server {
|
|
t.Helper()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
body := `{"success":true,"message":"sent"}`
|
|
if !success {
|
|
body = `{"success":false,"message":"mock failure"}`
|
|
}
|
|
_, _ = io.WriteString(w, body)
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
// configureEmailForTest attaches an email.Sender wired to the given
|
|
// mock endpoint plus a base URL so s.baseURL != "" (the configured
|
|
// check looks at both).
|
|
func configureEmailForTest(srv *Server, endpoint, baseURL string) {
|
|
sender := email.NewSender("test-key", "noreply@test.getpad.dev", "Pad Test", baseURL)
|
|
sender.SetEndpoint(endpoint)
|
|
srv.SetEmailSender(sender, "test-key")
|
|
srv.SetBaseURL(baseURL)
|
|
}
|
|
|
|
// TestPaymentFailed_HappyPath_SendsAndAudits covers the end-to-end
|
|
// success path: Maileroo returns 200/success, pad records an audit row
|
|
// with reason=sent and sent=true attached to the target user ID.
|
|
func TestPaymentFailed_HappyPath_SendsAndAudits(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "paying@example.com", "Paying User")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
payingUser, err := srv.store.GetUserByEmail("paying@example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetUserByEmail: %v", err)
|
|
}
|
|
if err := srv.store.SetUserStripeCustomerID(payingUser.ID, "cus_happy_path"); err != nil {
|
|
t.Fatalf("SetUserStripeCustomerID: %v", err)
|
|
}
|
|
|
|
mock := mockMailerooEndpoint(t, http.StatusOK, true)
|
|
configureEmailForTest(srv, mock.URL, "https://app.test.getpad.dev")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{
|
|
"stripe_customer_id": "cus_happy_path",
|
|
"amount_display": "10.00 USD",
|
|
"next_retry_display": "April 30, 2026",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("happy path: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if sent, _ := resp["email_sent"].(bool); !sent {
|
|
t.Errorf("email_sent=false on happy path; want true: %v", resp)
|
|
}
|
|
if got, _ := resp["reason"].(string); got != "sent" {
|
|
t.Errorf("reason=%q on happy path; want sent", got)
|
|
}
|
|
|
|
audit := paymentFailedAuditForUser(t, srv, payingUser.ID)
|
|
if len(audit) != 1 {
|
|
t.Fatalf("expected 1 audit row for happy path, got %d", len(audit))
|
|
}
|
|
if audit[0]["reason"] != "sent" || audit[0]["sent"] != "true" {
|
|
t.Errorf("audit metadata wrong: %v", audit[0])
|
|
}
|
|
if audit[0]["stripe_customer_id"] != "cus_happy_path" {
|
|
t.Errorf("audit missing stripe_customer_id: %v", audit[0])
|
|
}
|
|
}
|
|
|
|
// TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits covers
|
|
// the send_failed branch: Maileroo 500 → response stays 200 + reason=
|
|
// send_failed so the sidecar does not retry the Stripe webhook, and an
|
|
// audit row with reason=send_failed + sent=false is written against
|
|
// the target user ID.
|
|
func TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "unlucky@example.com", "Unlucky User")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
unluckyUser, err := srv.store.GetUserByEmail("unlucky@example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetUserByEmail: %v", err)
|
|
}
|
|
if err := srv.store.SetUserStripeCustomerID(unluckyUser.ID, "cus_send_fail"); err != nil {
|
|
t.Fatalf("SetUserStripeCustomerID: %v", err)
|
|
}
|
|
|
|
mock := mockMailerooEndpoint(t, http.StatusInternalServerError, false)
|
|
configureEmailForTest(srv, mock.URL, "https://app.test.getpad.dev")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{
|
|
"stripe_customer_id": "cus_send_fail",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("send_failed: expected 200 (so sidecar does not retry webhook), got %d: %s",
|
|
rr.Code, rr.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
if sent, _ := resp["email_sent"].(bool); sent {
|
|
t.Errorf("email_sent=true on send failure; want false")
|
|
}
|
|
if got, _ := resp["reason"].(string); got != "send_failed" {
|
|
t.Errorf("reason=%q on send failure; want send_failed", got)
|
|
}
|
|
|
|
audit := paymentFailedAuditForUser(t, srv, unluckyUser.ID)
|
|
if len(audit) != 1 {
|
|
t.Fatalf("expected 1 audit row for send_failed, got %d", len(audit))
|
|
}
|
|
if audit[0]["reason"] != "send_failed" || audit[0]["sent"] != "false" {
|
|
t.Errorf("audit metadata wrong: %v", audit[0])
|
|
}
|
|
}
|
|
|
|
// TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID confirms that
|
|
// the no_customer skip path also writes an audit row — just with no
|
|
// user filter, since we don't have a user to attribute it to. This is
|
|
// important for dunning reconciliation: an operator can still find the
|
|
// event via action + stripe_customer_id metadata.
|
|
func TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("shh-its-a-secret")
|
|
|
|
req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{
|
|
"stripe_customer_id": "cus_totally_unknown",
|
|
"cloud_secret": "shh-its-a-secret",
|
|
}, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
audit := paymentFailedAuditForUser(t, srv, "")
|
|
// Filter to rows whose metadata customer ID matches, since the test
|
|
// DB might contain other payment_failed rows from earlier tests that
|
|
// share the same DB instance if testServer is ever pooled.
|
|
var found map[string]string
|
|
for _, row := range audit {
|
|
if row["stripe_customer_id"] == "cus_totally_unknown" {
|
|
found = row
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("expected audit row for cus_totally_unknown, got %d rows total", len(audit))
|
|
}
|
|
if found["reason"] != "no_customer" || found["sent"] != "false" {
|
|
t.Errorf("audit metadata wrong: %v", found)
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_HeaderSecret_StillAuthenticates confirms the header
|
|
// form (the only supported sidecar auth after TASK-656) still works on
|
|
// the GET endpoint that previously used query-param.
|
|
func TestCloudAdminGate_HeaderSecret_StillAuthenticates(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("header-only")
|
|
|
|
req := cloudAdminReq(t, "GET",
|
|
"/api/v1/admin/user-by-customer?customer_id=cus_unknown",
|
|
nil, map[string]string{"X-Cloud-Secret": "header-only"})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
// 404 from the handler (no user maps to the customer ID) — not 401/403.
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("expected handler-level 404, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// cloudAdminCSRFToken is a fixed 64-hex-char (32-byte) value used across
|
|
// the tests below as a matching CSRF double-submit pair (cookie + header).
|
|
// Its value is arbitrary; only its length/format need to satisfy
|
|
// middleware_csrf.go's csrfTokenLen check.
|
|
const cloudAdminCSRFToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
|
// TestCloudAdminGate_GarbageSecretNoSession_ReachesHandlerRejection is a
|
|
// regression lock for BUG-1944: RequireAuth's cloud-secret bypass must
|
|
// still fire for a genuine no-session sidecar-shaped caller (currentUser
|
|
// == nil) even when the marker's secret value is wrong — the request
|
|
// still needs to reach the handler so handler-level validateCloudSecret
|
|
// can reject it. This proves the currentUser(r) == nil gate didn't turn
|
|
// the marker-presence bypass into a validated-secret bypass; it only
|
|
// narrows it by session state.
|
|
func TestCloudAdminGate_GarbageSecretNoSession_ReachesHandlerRejection(t *testing.T) {
|
|
srv := testServer(t)
|
|
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("real-secret")
|
|
|
|
req := cloudAdminReq(t, "GET",
|
|
"/api/v1/admin/user-by-customer?customer_id=cus_unknown",
|
|
nil, map[string]string{"X-Cloud-Secret": "totally-wrong"})
|
|
// No cookies at all — genuine no-session caller.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
// Must be the handler's own "Invalid cloud secret" rejection (403,
|
|
// code "forbidden") — not RequireAuth 401ing it outright, and
|
|
// obviously not a 2xx pass-through.
|
|
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "Invalid cloud secret") {
|
|
t.Fatalf("expected handler-level validateCloudSecret rejection, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_DisabledAdminSession_GarbageSecret_RejectedByRequireAuth
|
|
// is the regression test for the concrete gap BUG-1944's fix closes: before
|
|
// the currentUser(r) == nil gate, RequireAuth's marker-presence bypass fired
|
|
// regardless of session state, which meant it also skipped RequireAuth's
|
|
// own user.IsDisabled() check for ANY request carrying a cloud-secret
|
|
// marker on a cloud-admin path — including one with the WRONG secret. A
|
|
// disabled admin whose session cookie hadn't been revoked could ride that
|
|
// straight into handleSetPlan, which trusts a resolved admin session as an
|
|
// alternative to validateCloudSecret.
|
|
//
|
|
// Asserts on RequireAuth's specific "account_disabled" error code (not
|
|
// merely a 403 status, which handler-level validateCloudSecret also
|
|
// returns) so the test can't pass for the wrong reason, and additionally
|
|
// verifies the target user's plan was never touched — proof the handler
|
|
// never ran at all.
|
|
func TestCloudAdminGate_DisabledAdminSession_GarbageSecret_RejectedByRequireAuth(t *testing.T) {
|
|
srv := testServer(t)
|
|
adminToken := bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("real-secret")
|
|
|
|
admin, err := srv.store.GetUserByEmail("admin@example.com")
|
|
if err != nil || admin == nil {
|
|
t.Fatalf("lookup admin: %v", err)
|
|
}
|
|
target, err := srv.store.CreateUser(models.UserCreate{
|
|
Email: "target-disabled-admin@example.com", Name: "Target", Password: "correct-horse-battery-staple", Role: "member",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create target: %v", err)
|
|
}
|
|
beforePlan := target.Plan
|
|
|
|
if err := srv.store.DisableUserLeavingCredentialsForTesting(admin.ID); err != nil {
|
|
t.Fatalf("disable admin: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"user_id": target.ID,
|
|
"plan": "pro",
|
|
"cloud_secret": "totally-wrong",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plan", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Cloud-Secret", "totally-wrong")
|
|
req.Header.Set("X-CSRF-Token", cloudAdminCSRFToken)
|
|
req.RemoteAddr = "192.0.2.1:1"
|
|
req.AddCookie(&http.Cookie{Name: "pad_session", Value: adminToken})
|
|
req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: cloudAdminCSRFToken})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), `"account_disabled"`) {
|
|
t.Fatalf("expected RequireAuth's account_disabled rejection specifically, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// No side effect: the handler must never have run.
|
|
after, err := srv.store.GetUser(target.ID)
|
|
if err != nil {
|
|
t.Fatalf("reload target: %v", err)
|
|
}
|
|
if after.Plan != beforePlan {
|
|
t.Fatalf("handler ran despite disabled admin: plan changed from %q to %q", beforePlan, after.Plan)
|
|
}
|
|
}
|
|
|
|
// TestCloudAdminGate_ActiveAdminSession_GarbageSecret_StillSucceeds is the
|
|
// companion to the disabled-admin test above: a non-disabled admin session
|
|
// hitting a cloud-admin path with the WRONG cloud secret must still reach
|
|
// the handler and succeed via its in-handler isAdmin bypass — the
|
|
// currentUser(r) == nil gate must not newly break the legitimate
|
|
// browser-admin flow (e.g. manual reconciliation from the admin UI).
|
|
func TestCloudAdminGate_ActiveAdminSession_GarbageSecret_StillSucceeds(t *testing.T) {
|
|
srv := testServer(t)
|
|
adminToken := bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
|
|
srv.SetCloudMode("real-secret")
|
|
|
|
target, err := srv.store.CreateUser(models.UserCreate{
|
|
Email: "target-active-admin@example.com", Name: "Target", Password: "correct-horse-battery-staple", Role: "member",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create target: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"user_id": target.ID,
|
|
"plan": "pro",
|
|
"cloud_secret": "totally-wrong",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/plan", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Cloud-Secret", "totally-wrong")
|
|
req.Header.Set("X-CSRF-Token", cloudAdminCSRFToken)
|
|
req.RemoteAddr = "192.0.2.1:1"
|
|
req.AddCookie(&http.Cookie{Name: "pad_session", Value: adminToken})
|
|
req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: cloudAdminCSRFToken})
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected active admin session to update plan via in-handler isAdmin bypass, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
after, err := srv.store.GetUser(target.ID)
|
|
if err != nil {
|
|
t.Fatalf("reload target: %v", err)
|
|
}
|
|
if after.Plan != "pro" {
|
|
t.Fatalf("expected plan updated to pro, got %q", after.Plan)
|
|
}
|
|
}
|