Add lifecycle_status = 'published' to the public-catalog queries (plans and add-ons listings) and reject checkout before any Stripe call unless the product behind the price clears the shared member gate (published + active + public). The currently-enrolled ladder rung stays renderable even if its product is later drafted or retired, fetched directly so members keep seeing what they are on. Introduce a single evaluateMemberGate definition shared by the catalog paths and the operator readiness panel so the surfaces cannot disagree about what is publishable for members.
164 lines
4.9 KiB
Go
164 lines
4.9 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/stripe/stripe-go/v81/webhook"
|
|
)
|
|
|
|
func TestScrubPII(t *testing.T) {
|
|
input := map[string]interface{}{
|
|
"id": "evt_123",
|
|
"type": "customer.created",
|
|
"email": "user@example.com",
|
|
"name": "Alice",
|
|
"amount": float64(2000),
|
|
"object": map[string]interface{}{
|
|
"id": "cus_123",
|
|
"email": "nested@example.com",
|
|
"phone": "+15551234567",
|
|
"address": map[string]interface{}{"city": "Portland"},
|
|
"shipping": map[string]interface{}{"name": "Bob"},
|
|
"billing_details": map[string]interface{}{"email": "billing@x.com"},
|
|
"metadata": map[string]interface{}{"internal_id": "abc"},
|
|
},
|
|
"items": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "ii_123",
|
|
"email": "item@example.com",
|
|
},
|
|
},
|
|
}
|
|
|
|
result := ScrubPII(input).(map[string]interface{})
|
|
|
|
// Top-level PII should be redacted
|
|
if result["email"] != "[REDACTED]" {
|
|
t.Errorf("expected email redacted, got %v", result["email"])
|
|
}
|
|
if result["name"] != "[REDACTED]" {
|
|
t.Errorf("expected name redacted, got %v", result["name"])
|
|
}
|
|
|
|
// Non-PII should be preserved
|
|
if result["id"] != "evt_123" {
|
|
t.Errorf("expected id preserved, got %v", result["id"])
|
|
}
|
|
if result["amount"] != float64(2000) {
|
|
t.Errorf("expected amount preserved, got %v", result["amount"])
|
|
}
|
|
|
|
// Nested PII should be redacted
|
|
obj := result["object"].(map[string]interface{})
|
|
if obj["email"] != "[REDACTED]" {
|
|
t.Errorf("expected nested email redacted, got %v", obj["email"])
|
|
}
|
|
if obj["phone"] != "[REDACTED]" {
|
|
t.Errorf("expected nested phone redacted, got %v", obj["phone"])
|
|
}
|
|
if obj["address"] != "[REDACTED]" {
|
|
t.Errorf("expected address redacted, got %v", obj["address"])
|
|
}
|
|
if obj["shipping"] != "[REDACTED]" {
|
|
t.Errorf("expected shipping redacted, got %v", obj["shipping"])
|
|
}
|
|
if obj["billing_details"] != "[REDACTED]" {
|
|
t.Errorf("expected billing_details redacted, got %v", obj["billing_details"])
|
|
}
|
|
|
|
// Nested non-PII preserved
|
|
if obj["id"] != "cus_123" {
|
|
t.Errorf("expected nested id preserved, got %v", obj["id"])
|
|
}
|
|
meta := obj["metadata"].(map[string]interface{})
|
|
if meta["internal_id"] != "abc" {
|
|
t.Errorf("expected metadata preserved, got %v", meta["internal_id"])
|
|
}
|
|
|
|
// Array items scrubbed
|
|
items := result["items"].([]interface{})
|
|
item := items[0].(map[string]interface{})
|
|
if item["email"] != "[REDACTED]" {
|
|
t.Errorf("expected array item email redacted, got %v", item["email"])
|
|
}
|
|
if item["id"] != "ii_123" {
|
|
t.Errorf("expected array item id preserved, got %v", item["id"])
|
|
}
|
|
}
|
|
|
|
func TestScrubPII_PreservesJSONShape(t *testing.T) {
|
|
// Verify the scrubbed output is valid JSON
|
|
input := map[string]interface{}{
|
|
"customer": map[string]interface{}{
|
|
"id": "cus_abc",
|
|
"email": "test@test.com",
|
|
"name": "Test User",
|
|
"metadata": map[string]interface{}{
|
|
"plan": "pro",
|
|
},
|
|
},
|
|
}
|
|
|
|
result := ScrubPII(input)
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("scrubbed result should marshal to JSON: %v", err)
|
|
}
|
|
|
|
// Verify it round-trips
|
|
var parsed map[string]interface{}
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
t.Fatalf("scrubbed JSON should parse: %v", err)
|
|
}
|
|
|
|
customer := parsed["customer"].(map[string]interface{})
|
|
if customer["id"] != "cus_abc" {
|
|
t.Error("id should be preserved through round-trip")
|
|
}
|
|
if customer["email"] != "[REDACTED]" {
|
|
t.Error("email should be redacted through round-trip")
|
|
}
|
|
}
|
|
|
|
const testWebhookSecret = "whsec_test_insert_failure"
|
|
|
|
// TestServeHTTP_InsertFailureReturns500 drives the insert-error branch
|
|
// without a reachable database: a *sql.DB that has already been Close()d
|
|
// fails every subsequent operation with sql.ErrConnDone, which is the
|
|
// cleanest seam for proving nothing-recorded means non-2xx (D5). A live DB
|
|
// is not needed here — the duplicate-delivery/happy-path coverage against a
|
|
// real database lives in webhook_db_test.go.
|
|
func TestServeHTTP_InsertFailureReturns500(t *testing.T) {
|
|
database, err := sql.Open("pgx", "postgres://insert-failure-seam/testdb")
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
if err := database.Close(); err != nil {
|
|
t.Fatalf("close database: %v", err)
|
|
}
|
|
|
|
h := &StripeWebhookHandler{DB: database, WebhookSecret: testWebhookSecret, Logger: slog.Default()}
|
|
|
|
payload := []byte(`{"id": "evt_insert_failure", "type": "customer.created", "data": {"object": {"id": "cus_test"}}}`)
|
|
now := time.Now()
|
|
sig := webhook.ComputeSignature(now, payload, testWebhookSecret)
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", bytes.NewReader(payload))
|
|
req.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%x", now.Unix(), sig))
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("insert failure: status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
}
|