Files
member-console/internal/integrations/stripe/web/webhook_test.go
T
cgalo5758 efe3f1528d Restrict member surfaces to published products
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.
2026-08-22 12:58:05 -05:00

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)
}
}