mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
d895418ea2
Sibling of TASK-1932's CSRFProtect fix: RequireAuth's isCloudAdminPath + hasCloudSecretMarker bypass fired on marker presence, not validated secret. Mirror TASK-1932's currentUser(r) == nil gate exactly. Concretely closes a disabled-admin gap: without the gate, a marker with the wrong secret let RequireAuth's own user.IsDisabled() check be skipped whenever a session was present, reaching handlers that trust a resolved admin session as an alternative to validateCloudSecret. In-handler validation for every cloudAdminPaths handler is unchanged and remains the independent layer for the genuine no-session sidecar case.
1088 lines
42 KiB
Go
1088 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},
|
|
{"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.DisableUser(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)
|
|
}
|
|
}
|