Files
member-console/internal/integrations/stripe/workflows/webhook_workflow_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

150 lines
5.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/testsuite"
)
// One event's workflow, run in Temporal's test environment with the
// activities mocked: Temporal owns the retry schedule, the workflow owns
// only the outcome (done, or dead-lettered and failed).
func eventUnderTest() WebhookEvent {
return WebhookEvent{ID: 41, Provider: "stripe", ProviderEventID: "evt_wf_test", EventType: "invoice.paid"}
}
type deadLetterRecorder struct {
calls int
lastID int64
lastMsg string
}
func mockDeadLetter(env *testsuite.TestWorkflowEnvironment, rec *deadLetterRecorder) {
var acts *WebhookActivities
env.OnActivity(acts.MarkEventDeadLetter, mock.Anything, mock.Anything, mock.Anything).
Return(func(_ context.Context, id int64, msg string) error {
rec.calls++
rec.lastID, rec.lastMsg = id, msg
return nil
})
}
func TestEventWorkflowCompletesWhenProcessingSucceeds(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).Return(nil)
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if !env.IsWorkflowCompleted() || env.GetWorkflowError() != nil {
t.Fatalf("workflow must complete cleanly, got completed=%v err=%v", env.IsWorkflowCompleted(), env.GetWorkflowError())
}
if rec.calls != 0 {
t.Errorf("a successful event must not be dead-lettered, got %d calls", rec.calls)
}
}
// The audit's finding: a failed attempt used to be the end. Temporal's
// retry policy runs the activity again after the backoff, and a second
// attempt that succeeds finishes the event with nothing dead-lettered.
func TestEventWorkflowRetriesAFailedAttempt(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ WebhookEvent) error {
attempts++
if attempts < 3 {
return errors.New("resolve invoice mapping: no rows")
}
return nil
})
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow must complete once an attempt succeeds, got %v", err)
}
if attempts != 3 {
t.Errorf("processing must have been attempted three times, got %d", attempts)
}
if rec.calls != 0 {
t.Errorf("a recovered event must not be dead-lettered, got %d calls", rec.calls)
}
}
// A handler that declares its error non-retryable (checkedInt32's
// AmountOutOfRange) ends the retries at once: the row is dead-lettered with
// that error and the workflow fails, so the Temporal UI shows it too.
func TestEventWorkflowDeadLettersATerminalFailure(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ WebhookEvent) error {
attempts++
return temporal.NewNonRetryableApplicationError("amount_paid is out of int32 range", "AmountOutOfRange", nil)
})
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if err := env.GetWorkflowError(); err == nil {
t.Fatal("the workflow must fail so the Temporal UI shows the dead letter")
}
if attempts != 1 {
t.Errorf("a terminal failure must not be retried, got %d attempts", attempts)
}
if rec.calls != 1 || rec.lastID != 41 || !strings.Contains(rec.lastMsg, "out of int32 range") {
t.Errorf("the row must be dead-lettered once with the handler's error, got calls=%d id=%d msg=%q", rec.calls, rec.lastID, rec.lastMsg)
}
}
// When every attempt within the budget fails, the schedule-to-close
// timeout ends the retries and the event is dead-lettered.
func TestEventWorkflowDeadLettersWhenTheBudgetIsSpent(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ WebhookEvent) error {
attempts++
return errors.New("database unavailable")
})
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if err := env.GetWorkflowError(); err == nil {
t.Fatal("the workflow must fail once the budget is spent")
}
if attempts < 10 {
t.Errorf("the budget must allow many attempts before giving up, got %d", attempts)
}
if rec.calls != 1 {
t.Errorf("the row must be dead-lettered exactly once, got %d", rec.calls)
}
}