Files
cgalo5758 88db730fcc Add dual licensing and SPDX headers
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.
2026-09-06 02:29:42 -05:00

276 lines
10 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/testsuite"
)
// createSagaRecorder mocks every activity CreateFedWikiSiteWorkflow can run and
// records the call order plus the inputs the assertions care about. A nil
// *Activities is sufficient: OnActivity reflects on the method value only to
// derive the registered activity name, and the mock intercepts before the (nil)
// receiver is dereferenced — the same idiom sync_test.go uses.
type createSagaRecorder struct {
order []string
createInput CreateFedWikiSiteInput
releasedSiteID string
}
const testAllocatedSiteID = "01927f2c-0000-7000-8000-00000000abcd"
// registerCreateSagaMocks wires the four always-present activities. farmErr, when
// non-nil, fails the farm-side create so the compensation path runs; allocErr
// fails the allocation instead (and the farm call must then never happen).
func registerCreateSagaMocks(env *testsuite.TestWorkflowEnvironment, rec *createSagaRecorder, allocErr, farmErr error) {
var acts *Activities
env.OnActivity(acts.CheckQuotaActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { rec.order = append(rec.order, "check-quota") }).
Return(&CheckQuotaOutput{CurrentCount: 0, Quota: 3, CanCreate: true}, nil)
env.OnActivity(acts.IncrementUsageActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { rec.order = append(rec.order, "increment-usage") }).
Return(nil)
allocate := env.OnActivity(acts.AllocateDomainActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { rec.order = append(rec.order, "allocate-domain") })
if allocErr != nil {
allocate.Return(nil, allocErr)
} else {
allocate.Return(&AllocateDomainOutput{SiteID: testAllocatedSiteID, Domain: "mysite.localtest.me"}, nil)
}
create := env.OnActivity(acts.CreateFedWikiSiteActivity, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
rec.order = append(rec.order, "create-site")
if in, ok := args.Get(1).(CreateFedWikiSiteInput); ok {
rec.createInput = in
}
})
if farmErr != nil {
create.Return(nil, farmErr)
} else {
create.Return(&CreateFedWikiSiteOutput{SiteID: testAllocatedSiteID, Domain: "mysite.localtest.me"}, nil)
}
env.OnActivity(acts.ReleaseDomainActivity, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
rec.order = append(rec.order, "release-domain")
if in, ok := args.Get(1).(ReleaseDomainInput); ok {
rec.releasedSiteID = in.SiteID
}
}).
Return(nil)
env.OnActivity(acts.DecrementUsageActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { rec.order = append(rec.order, "decrement-usage") }).
Return(nil)
}
func createSagaInput() CreateFedWikiSiteWorkflowInput {
return CreateFedWikiSiteWorkflowInput{
WorkspaceID: "workspace-abc",
Domain: "mysite",
SiteDomain: "localtest.me",
OwnerName: "Tester",
OwnerID: "sub-tester",
}
}
// TestCreateSagaReusesTheSiteIDAcrossAllocationRetries reproduces the
// at-least-once hazard the reservation has to survive: the allocation activity
// commits its placement and then loses its result (worker crash, dropped
// connection), so Temporal runs it again.
//
// Every attempt MUST carry the same site id. An id minted inside the activity
// differs per attempt, so the retry reserves under a fresh id, collides with
// the placement its own first attempt committed, and — because a namespace
// refusal is permanent by construction — strands that placement and its carved
// claim while telling the member the name is taken. Minting it in workflow
// state (a side effect, recorded in history) is what makes the retry converge.
func TestCreateSagaReusesTheSiteIDAcrossAllocationRetries(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *Activities
env.OnActivity(acts.CheckQuotaActivity, mock.Anything, mock.Anything).
Return(&CheckQuotaOutput{CurrentCount: 0, Quota: 3, CanCreate: true}, nil)
env.OnActivity(acts.IncrementUsageActivity, mock.Anything, mock.Anything).Return(nil)
var seen []string
env.OnActivity(acts.AllocateDomainActivity, mock.Anything, mock.Anything).
Return(func(_ context.Context, in AllocateDomainInput) (*AllocateDomainOutput, error) {
seen = append(seen, in.SiteID)
if len(seen) == 1 {
// Committed, then the result was lost on the way back.
return nil, errors.New("connection reset")
}
return &AllocateDomainOutput{SiteID: in.SiteID, Domain: "mysite.localtest.me"}, nil
})
env.OnActivity(acts.CreateFedWikiSiteActivity, mock.Anything, mock.Anything).
Return(func(_ context.Context, in CreateFedWikiSiteInput) (*CreateFedWikiSiteOutput, error) {
return &CreateFedWikiSiteOutput{SiteID: in.SiteID, Domain: in.Domain}, nil
})
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
if len(seen) < 2 {
t.Fatalf("allocation ran %d time(s), want the retry the test sets up", len(seen))
}
if seen[0] == "" {
t.Error("the workflow supplied no site id; the activity would have to mint one per attempt")
}
for i, got := range seen[1:] {
if got != seen[0] {
t.Errorf("attempt %d reserved under site id %q, want the first attempt's %q", i+2, got, seen[0])
}
}
}
func indexOf(order []string, name string) int {
for i, got := range order {
if got == name {
return i
}
}
return -1
}
// TestCreateSagaReservesNameBeforeFarmCall pins the reserve-then-bind ordering
// (design D8): quota is reserved, then the name, and only then is the farm
// called — with the site id the allocation pre-generated, so the fedwiki.sites
// row carries the identity the placement already points at.
func TestCreateSagaReservesNameBeforeFarmCall(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
rec := &createSagaRecorder{}
registerCreateSagaMocks(env, rec, nil, nil)
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out *CreateFedWikiSiteWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if !out.Success {
t.Fatalf("Success = false, want true (%q)", out.ErrorMessage)
}
want := []string{"check-quota", "increment-usage", "allocate-domain", "create-site"}
if len(rec.order) != len(want) {
t.Fatalf("activity order = %v, want %v", rec.order, want)
}
for i := range want {
if rec.order[i] != want[i] {
t.Fatalf("activity order = %v, want %v", rec.order, want)
}
}
if rec.createInput.SiteID != testAllocatedSiteID {
t.Errorf("CreateFedWikiSiteInput.SiteID = %q, want the allocated id %q", rec.createInput.SiteID, testAllocatedSiteID)
}
}
// TestCreateSagaCompensatesAllocationOnFarmFailure asserts the compensation
// path: a farm-side failure frees the reserved name AND the reserved quota
// slot, registry first (the design D3 lock-ordering invariant).
func TestCreateSagaCompensatesAllocationOnFarmFailure(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
rec := &createSagaRecorder{}
farmErr := temporal.NewNonRetryableApplicationError(
"The FedWiki farm could not create your site.", "FarmManagerAPIError", nil)
registerCreateSagaMocks(env, rec, nil, farmErr)
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow returned an error, want friendly output: %v", err)
}
var out *CreateFedWikiSiteWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if out.Success {
t.Error("Success = true, want false after a farm failure")
}
release, decrement := indexOf(rec.order, "release-domain"), indexOf(rec.order, "decrement-usage")
if release < 0 {
t.Fatalf("ReleaseDomainActivity was never called; order = %v", rec.order)
}
if decrement < 0 {
t.Fatalf("DecrementUsageActivity was never called; order = %v", rec.order)
}
if release > decrement {
t.Errorf("compensation order = %v, want the registry release before the usage decrement", rec.order)
}
if rec.releasedSiteID != testAllocatedSiteID {
t.Errorf("released site id = %q, want the allocated id %q", rec.releasedSiteID, testAllocatedSiteID)
}
}
// TestCreateSagaAllocationFailureSkipsFarmAndReleases asserts that a refused
// name never reaches the farm, compensates only the quota slot (nothing was
// placed to release), and surfaces the activity's member-facing message.
func TestCreateSagaAllocationFailureSkipsFarmAndReleases(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
rec := &createSagaRecorder{}
const refusal = "That name isn't available. Please choose another."
registerCreateSagaMocks(env, rec, temporal.NewNonRetryableApplicationError(refusal, "InvalidDomain", nil), nil)
env.ExecuteWorkflow(CreateFedWikiSiteWorkflow, createSagaInput())
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow returned an error, want friendly output: %v", err)
}
var out *CreateFedWikiSiteWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if out.Success {
t.Error("Success = true, want false when the name could not be allocated")
}
if out.ErrorMessage != refusal {
t.Errorf("ErrorMessage = %q, want the activity's refusal %q", out.ErrorMessage, refusal)
}
if i := indexOf(rec.order, "create-site"); i >= 0 {
t.Errorf("the farm was called despite a failed allocation; order = %v", rec.order)
}
if i := indexOf(rec.order, "release-domain"); i >= 0 {
t.Errorf("ReleaseDomainActivity ran with nothing placed; order = %v", rec.order)
}
if i := indexOf(rec.order, "decrement-usage"); i < 0 {
t.Errorf("the reserved quota slot was not compensated; order = %v", rec.order)
}
}