- 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
150 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|