Files
member-console/internal/integrations/stripe/web/webhook_db_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

207 lines
7.8 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// DB-backed webhook tests live in an external test package: an in-package
// test importing internal/migrate would close the test-binary-only cycle
// web(test) → migrate → integrations → stripe adapter → web
// (docs/building-an-integration.md §8).
package web_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/stripe/stripe-go/v81/webhook"
)
const testSecret = "whsec_test_dedupe"
func testDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.RunMigrations(database, migrate.Sources()); err != nil {
t.Fatalf("run migrations: %v", err)
}
t.Cleanup(func() { database.Close() })
return database
}
// deliver POSTs payload to the handler with a valid Stripe signature.
func deliver(t *testing.T, h *web.StripeWebhookHandler, payload []byte) int {
t.Helper()
now := time.Now()
sig := webhook.ComputeSignature(now, payload, testSecret)
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)
return rec.Code
}
// Scenario: Stripe redelivers the same event at a later instant (its retry
// behavior on timeouts). The recording must dedupe on
// (provider, provider_event_id) across time — the partitioned table's
// ON CONFLICT target includes received_at and only collapses same-instant
// duplicates, which is how a redelivery used to insert (and reprocess) a
// second row.
func TestWebhookDuplicateDeliveryAcrossTime(t *testing.T) {
database := testDB(t)
h := &web.StripeWebhookHandler{DB: database, WebhookSecret: testSecret, Logger: slog.Default()}
eventID := "evt_test_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(
`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
payload := []byte(fmt.Sprintf(
`{"id": %q, "type": "customer.created", "data": {"object": {"id": "cus_test", "email": "dup@example.com"}}}`,
eventID))
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("first delivery = %d, want 200", code)
}
time.Sleep(20 * time.Millisecond) // distinct received_at — the case ON CONFLICT misses
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("second delivery = %d, want 200", code)
}
var rows int
if err := database.QueryRow(
`SELECT COUNT(*) FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`,
eventID).Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 1 {
t.Fatalf("duplicate delivery recorded %d rows, want 1", rows)
}
// The recorded payload must be the scrubbed form, not the raw body.
var stored string
if err := database.QueryRow(
`SELECT payload::text FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`,
eventID).Scan(&stored); err != nil {
t.Fatalf("read payload: %v", err)
}
if bytes.Contains([]byte(stored), []byte("dup@example.com")) {
t.Errorf("stored payload retains PII email: %s", stored)
}
}
// recordingStarter stands in for the per-event workflow start: it records
// what the endpoint asked to start and can refuse, the way a Temporal
// outage would.
type recordingStarter struct {
calls []web.QueuedEvent
err error
}
func (s *recordingStarter) start(_ context.Context, e web.QueuedEvent) error {
s.calls = append(s.calls, e)
return s.err
}
// The endpoint records the event, then starts its workflow, and answers 200
// only when both happened (stripe-integration-infrastructure "Stripe webhook
// endpoint ingests events asynchronously", 2026-09 audit remediation D8).
func TestWebhookStartsTheEventWorkflow(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{}
h := &web.StripeWebhookHandler{DB: database, WebhookSecret: testSecret, Logger: slog.Default(), StartProcessing: starter.start}
eventID := "evt_start_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
payload := []byte(fmt.Sprintf(`{"id": %q, "type": "customer.created", "created": 1757400000, "data": {"object": {"id": "cus_start"}}}`, eventID))
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
var rowID int64
var eventAt time.Time
if err := database.QueryRow(`SELECT id, provider_event_at FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&rowID, &eventAt); err != nil {
t.Fatalf("read row: %v", err)
}
if eventAt.Unix() != 1757400000 {
t.Errorf("the event's own time must be recorded, got %v", eventAt)
}
if len(starter.calls) != 1 || starter.calls[0] != (web.QueuedEvent{ID: rowID, ProviderEventID: eventID, EventType: "customer.created"}) {
t.Fatalf("the workflow must be started once with the recorded row, got %+v", starter.calls)
}
// A redelivery of an unfinished event starts the workflow again (a
// no-op when it is running), which is what recovers a process that died
// between recording and starting.
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("redelivery = %d, want 200", code)
}
if len(starter.calls) != 2 {
t.Errorf("a redelivery of an unfinished event must re-issue the start, got %d calls", len(starter.calls))
}
// A redelivery of a finished event is acknowledged and starts nothing.
if _, err := database.Exec(`UPDATE core.webhook_events SET status = 'completed' WHERE id = $1`, rowID); err != nil {
t.Fatal(err)
}
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("redelivery of a finished event = %d, want 200", code)
}
if len(starter.calls) != 2 {
t.Errorf("a finished event must not be started again, got %d calls", len(starter.calls))
}
}
// A start that fails leaves the row recorded and answers 500, so Stripe
// redelivers; the redelivery, with Temporal back, starts the workflow.
func TestWebhookAnswers500WhenTheWorkflowCannotStart(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{err: errors.New("temporal unavailable")}
h := &web.StripeWebhookHandler{DB: database, WebhookSecret: testSecret, Logger: slog.Default(), StartProcessing: starter.start}
eventID := "evt_nostart_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
payload := []byte(fmt.Sprintf(`{"id": %q, "type": "customer.created", "data": {"object": {"id": "cus_nostart"}}}`, eventID))
if code := deliver(t, h, payload); code != http.StatusInternalServerError {
t.Fatalf("delivery with no Temporal = %d, want 500", code)
}
var rows int
if err := database.QueryRow(`SELECT COUNT(*) FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("the event must stay recorded, got %d rows", rows)
}
starter.err = nil
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("redelivery with Temporal back = %d, want 200", code)
}
if len(starter.calls) != 2 {
t.Errorf("the redelivery must start the workflow, got %d calls", len(starter.calls))
}
}