Files
member-console/internal/integrations/fedwiki/workflows/workflow_test.go
T
cgalo5758 8d05934e93 Add domains registry with claims and placements
Domain names become an allocatable resource with one authority. A new
core module (schema `domains`, own migration stream between core and the
integrations) owns claims — a DNS node plus its whole subtree, mutually
disjoint: operator shared-domain roots, member claims carved from them,
and bring-your-own names proven by TXT verification — and placements,
which bind a name inside a claim to a provider slug and resource ref.

Verification moves to the claim and decouples from creation. A member
proves control of a domain once; afterwards every name inside it places
instantly, wildcard-CNAME friendly, with no further DNS work. The claim
workflow activates the claim and stops — it no longer creates a site —
so the sites list offers a one-click create once a domain verifies.

/domains/ask answers from placements and is registered by core rather
than the FedWiki adapter; its HTTP contract is unchanged. A configured
`domains-ask-fallback-url` forwards names the registry does not know to
a legacy answerer, the strangler seam wiki.cafe's migration needs; a
name the registry knows but has archived is refused locally.

FedWiki's create saga reserves the name before the farm call, carrying a
workflow-minted site id so retries are idempotent, and compensates on
failure. Sync places only names it owns, never stealing a member's;
lifecycle transitions and the retention purge maintain servability. An
unconditional boot pass seeds operator roots, releases orphaned
placements, and adopts pre-existing sites — grandfathering member-owned
external domains shortest-name-first, and skipping name policy, so a
live single-letter site cannot lose its certificate.

Members manage domains at /domains: claims with verification status, DNS
records including an optional wildcard row, check-now, cancel, release.
Name policy (reserved, blocked, premium, plus a single-letter guard) is
operator data; refusals collapse to a plain "unavailable" so the console
never becomes an oracle for who holds what.

BREAKING (pre-release): `fedwiki.custom_domain_verifications` and
`sites.is_custom_domain` are dropped, the flag now derived from the
placement's claim kind; resource key `fedwiki_custom_domains` migrates
to the platform-owned `external_domain_claims`; running
verify-custom-domain workflows must be terminated before deploy.
2026-07-24 21:25:40 -05:00

273 lines
10 KiB
Go

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)
}
}