Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree.
167 lines
5.1 KiB
Go
167 lines
5.1 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
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)
|
|
}
|
|
}
|