Files
member-console/internal/workflows/domains/verify_test.go
T
cgalo5758 c85ac6acdc Add domain claim lifecycle safeguards
Make claim windows and workspace caps configurable, and enforce
initiation
and abandonment budgets without penalizing DNS evidence or system
failures.
Add operator visibility into live claims and default verification to 24
hours.
2026-07-25 00:40:24 -05:00

832 lines
32 KiB
Go

package domains
import (
"errors"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
dommod "git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/converter"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/testsuite"
"go.temporal.io/sdk/workflow"
)
const (
testRoot = "example.org"
testTarget = "connect.example.test"
)
func TestMatchesChallengeToken(t *testing.T) {
token := "abc123"
want := ChallengeRecordValue(token)
tests := []struct {
name string
records []string
want bool
}{
{"exact match", []string{want}, true},
{"match among unrelated records", []string{"v=spf1 include:example.org ~all", want}, true},
{"surrounding whitespace tolerated", []string{" " + want + " "}, true},
{"wrong token", []string{ChallengeRecordValue("other")}, false},
{"prefix without token", []string{"member-console-verify="}, false},
{"no records", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchesChallengeToken(tt.records, token); got != tt.want {
t.Errorf("matchesChallengeToken(%v) = %v, want %v", tt.records, got, tt.want)
}
})
}
}
func TestChallengeRecordName(t *testing.T) {
if got := ChallengeRecordName(testRoot); got != "_member-console-challenge.example.org" {
t.Errorf("ChallengeRecordName = %q", got)
}
}
func TestClassifyTXT(t *testing.T) {
token := "abc123"
want := ChallengeRecordValue(token)
tests := []struct {
name string
records []string
wantState string
wantEvidence bool
}{
{"expected value present", []string{want}, dommod.ProbeStateMatch, true},
{"match among unrelated records", []string{"v=spf1 ~all", want}, dommod.ProbeStateMatch, true},
{"records but none match", []string{ChallengeRecordValue("typo")}, dommod.ProbeStateMismatch, true},
{"foreign records only", []string{"v=spf1 ~all"}, dommod.ProbeStateMismatch, false},
{"no records", nil, dommod.ProbeStateMissing, false},
{"only blank records", []string{" "}, dommod.ProbeStateMissing, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state, _, evidence := classifyTXT(tt.records, token)
if state != tt.wantState {
t.Errorf("classifyTXT(%v) state = %q, want %q", tt.records, state, tt.wantState)
}
if evidence != tt.wantEvidence {
t.Errorf("classifyTXT(%v) evidence = %v, want %v", tt.records, evidence, tt.wantEvidence)
}
})
}
t.Run("mismatch carries observed values", func(t *testing.T) {
state, observed, _ := classifyTXT([]string{ChallengeRecordValue("typo")}, token)
if state != dommod.ProbeStateMismatch || len(observed) != 1 || observed[0] != ChallengeRecordValue("typo") {
t.Errorf("classifyTXT mismatch = (%q, %v), want observed typo value", state, observed)
}
})
}
// TestClassifyTXTReadsEvidenceAndMatchPastTheObservedCap: `observed` is
// display data and stops at maxObservedRecords, but both policy predicates —
// does this prove control (match), and did the member touch their zone
// (evidence) — must see the whole resolver answer. A zone serving more than
// eight TXT records at the challenge name is unusual but entirely legal, and a
// member whose challenge record sorted last would otherwise verify while still
// being charged an abandonment for it.
func TestClassifyTXTReadsEvidenceAndMatchPastTheObservedCap(t *testing.T) {
token := "abc123"
buried := make([]string, 0, maxObservedRecords+1)
for i := 0; i < maxObservedRecords; i++ {
buried = append(buried, fmt.Sprintf("v=spf1 include:_s%d.example ~all", i))
}
buried = append(buried, ChallengeRecordValue(token))
state, observed, evidence := classifyTXT(buried, token)
if len(observed) != maxObservedRecords {
t.Fatalf("observed kept %d values, want the cap %d — the fixture must bury the record", len(observed), maxObservedRecords)
}
for _, v := range observed {
if strings.HasPrefix(v, challengeValuePrefix) {
t.Fatalf("observed = %v, want the challenge record cut off by the cap", observed)
}
}
if state != dommod.ProbeStateMatch {
t.Errorf("state = %q, want %q — the match predicate reads the raw records", state, dommod.ProbeStateMatch)
}
if !evidence {
t.Error("evidence = false for a challenge record past the observed cap; the latch must read the raw records too")
}
}
func TestClassifyConnect(t *testing.T) {
tests := []struct {
name string
canonical string
nameAddrs []string
targetAddrs []string
wantState string
}{
{"cname to target", testTarget, nil, nil, dommod.ProbeStateMatch},
{"cname elsewhere, no shared address", "other.example.net", []string{"192.0.2.1"}, []string{"198.51.100.1"}, dommod.ProbeStateMismatch},
{"cname elsewhere but addresses intersect (chain)", "cdn.example.net", []string{"198.51.100.1"}, []string{"198.51.100.1"}, dommod.ProbeStateMatch},
{"no cname, addresses intersect (A record)", testRoot, []string{"198.51.100.1"}, []string{"198.51.100.1"}, dommod.ProbeStateMatch},
{"no cname, addresses differ", testRoot, []string{"192.0.2.1"}, []string{"198.51.100.1"}, dommod.ProbeStateMismatch},
{"no cname, no addresses", testRoot, nil, []string{"198.51.100.1"}, dommod.ProbeStateMissing},
{"ip-literal target published", "", []string{"198.51.100.1"}, []string{"198.51.100.1"}, dommod.ProbeStateMatch},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state, _ := classifyConnect(testRoot, testTarget, tt.canonical, tt.nameAddrs, tt.targetAddrs)
if state != tt.wantState {
t.Errorf("classifyConnect(%q, addrs %v vs %v) = %q, want %q",
tt.canonical, tt.nameAddrs, tt.targetAddrs, state, tt.wantState)
}
})
}
}
func TestNormalizeDNSName(t *testing.T) {
if got := normalizeDNSName(" Connect.Example.TEST. "); got != testTarget {
t.Errorf("normalizeDNSName = %q", got)
}
}
func TestCapObserved(t *testing.T) {
long := strings.Repeat("x", maxObservedValueLen+50)
many := make([]string, maxObservedRecords+5)
for i := range many {
many[i] = "v"
}
if got := capObserved(many); len(got) != maxObservedRecords {
t.Errorf("capObserved kept %d values, want %d", len(got), maxObservedRecords)
}
if got := capObserved([]string{long}); len(got[0]) > maxObservedValueLen+len("…") {
t.Errorf("capObserved kept %d bytes, want ≤ %d", len(got[0]), maxObservedValueLen+len("…"))
}
}
// TestCapObservedKeepsValuesStorable guards the storage contract: observed
// values are member-controlled bytes headed for a `text` column, and Postgres
// rejects the whole INSERT for one invalid byte sequence — which would burn
// RecordProbeActivity's retries and strand the probe at 'unchecked'. Byte
// slicing a multi-byte value is the way this package produced such a sequence
// from input that arrived perfectly valid.
func TestCapObservedKeepsValuesStorable(t *testing.T) {
cases := []struct {
name string
value string
}{
// Multi-byte runes straddling the cap: the naive v[:max] cut here.
{"multibyte at the cap", strings.Repeat("é", maxObservedValueLen)},
{"wide runes at the cap", strings.Repeat("🌍", maxObservedValueLen)},
// A resolver can hand back arbitrary bytes; they must be repaired,
// not passed through.
{"invalid bytes", "value\xff\xfe" + strings.Repeat("z", maxObservedValueLen)},
{"invalid bytes only", "\xff\xfe"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, got := range capObserved([]string{tc.value}) {
if !utf8.ValidString(got) {
t.Errorf("capObserved(%q) = %q, which is not valid UTF-8", tc.value, got)
}
if len(got) > maxObservedValueLen+len("…") {
t.Errorf("capObserved kept %d bytes, want ≤ %d", len(got), maxObservedValueLen+len("…"))
}
}
})
}
}
// stubProbes wires the standard activity stubs for the poll loop: connect
// probe answers, and probe recording is accepted and captured.
func stubProbes(env *testsuite.TestWorkflowEnvironment, connect *CheckConnectOutput, lastProbe *RecordProbeInput) {
var acts *Activities
env.OnActivity(acts.CheckConnectActivity, mock.Anything, mock.Anything).
Return(connect, nil)
env.OnActivity(acts.RecordProbeActivity, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
if in, ok := args.Get(1).(RecordProbeInput); ok && lastProbe != nil {
*lastProbe = in
}
}).
Return(nil)
}
// assertNoChildWorkflow records any child workflow the run starts.
// Verification is decoupled from creation (design D4): activating a claim
// must never hand off to a provider's creation saga.
func assertNoChildWorkflow(t *testing.T, env *testsuite.TestWorkflowEnvironment) {
t.Helper()
env.SetOnChildWorkflowStartedListener(func(info *workflow.Info, _ workflow.Context, _ converter.EncodedValues) {
t.Errorf("started child workflow %q, want none — verification creates no provider resource", info.WorkflowType.Name)
})
}
func verifyInput(root string, expiresAt time.Time) VerifyClaimWorkflowInput {
return VerifyClaimWorkflowInput{
ClaimID: "claim-1",
WorkspaceID: "ws-1",
Root: root,
Token: "tok",
ConnectTarget: testTarget,
ExpiresAt: expiresAt,
}
}
// TestVerifyClaimWorkflowActivatesClaim: the TXT record is found, the claim
// is marked active, and the workflow stops there — even while the connect
// record still mismatches (TXT alone gates verification).
func TestVerifyClaimWorkflowActivatesClaim(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
var acts *Activities
markedActive := 0
var lastProbe RecordProbeInput
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMismatch, Observed: []string{"elsewhere.example.net"}}, &lastProbe)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil)
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { markedActive++ }).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, time.Now().Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if !out.Success || !out.Verified || out.Root != testRoot {
t.Errorf("output = %+v, want verified success for %s", out, testRoot)
}
if markedActive != 1 {
t.Errorf("MarkClaimActiveActivity called %d times, want exactly 1", markedActive)
}
if lastProbe.ClaimID != "claim-1" {
t.Errorf("recorded probe claim = %q, want claim-1", lastProbe.ClaimID)
}
if lastProbe.TXTState != dommod.ProbeStateMatch || lastProbe.ConnectState != dommod.ProbeStateMismatch {
t.Errorf("recorded probe = %+v, want TXT match with connect mismatch", lastProbe)
}
}
// TestVerifyClaimWorkflowPollsUntilFound: the record is absent for the first
// two polls and found on the third — the loop keeps polling rather than
// giving up.
func TestVerifyClaimWorkflowPollsUntilFound(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
var acts *Activities
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMissing}, nil).Twice()
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil).Once()
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, time.Now().Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if !out.Success || !out.Verified {
t.Errorf("output = %+v, want verified success after polling", out)
}
env.AssertExpectations(t)
}
// TestVerifyClaimWorkflowCheckNowShortCircuits: a check-now signal during a
// long backoff wait probes immediately instead of waiting out the timer, and
// resets the backoff.
func TestVerifyClaimWorkflowCheckNowShortCircuits(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
// Missing at t=0 and t=30s; the third probe only matches when the signal
// forces it — the natural schedule would run it at t=90s (30s + 60s).
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMissing}, nil).Twice()
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil).Once()
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Return(nil)
// Fire check-now at t=35s, inside the second (60s) backoff window.
env.RegisterDelayedCallback(func() {
env.SignalWorkflow(CheckNowSignal, nil)
}, 35*time.Second)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if !out.Success || !out.Verified {
t.Errorf("output = %+v, want verified success via check-now", out)
}
// The signal-triggered probe verified at ~t=35s; without it the third
// probe wouldn't run before t=90s.
if elapsed := env.Now().Sub(start); elapsed >= 90*time.Second {
t.Errorf("workflow finished after %v, want the signal to beat the 90s natural schedule", elapsed)
}
env.AssertExpectations(t)
}
// TestVerifyClaimWorkflowCheckNowResetsBackoff: after a check-now the
// cadence returns to the 30s initial interval instead of resuming the
// doubled wait — the next unsignaled probe lands 30s after the signal.
func TestVerifyClaimWorkflowCheckNowResetsBackoff(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
// Probes at t=0 and t=30s (backoff now 60s), a signal-forced one at
// t=35s, and then — because the signal reset the cadence to 30s rather
// than resuming the doubled wait — the matching fourth probe at t=65s
// instead of t=95s.
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMissing}, nil).Times(3)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil).Once()
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Return(nil)
env.RegisterDelayedCallback(func() {
env.SignalWorkflow(CheckNowSignal, nil)
}, 35*time.Second)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
elapsed := env.Now().Sub(start)
if elapsed < 60*time.Second || elapsed >= 90*time.Second {
t.Errorf("workflow finished after %v, want the post-signal probe on the reset 30s cadence (~65s)", elapsed)
}
env.AssertExpectations(t)
}
// TestVerifyClaimWorkflowFinalProbeAtDeadline: a record published inside the
// window but during the last backoff interval is still caught — the wait is
// clamped to ExpiresAt and expiry is only declared after a final probe at
// the deadline, so there is no unprobed dead zone.
func TestVerifyClaimWorkflowFinalProbeAtDeadline(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
// Missing at t=0 and t=30s; the third probe (clamped to fire exactly at
// the t=45s deadline instead of the natural 60s backoff) matches.
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMissing}, nil).Twice()
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil).Once()
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(45*time.Second)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if !out.Success || !out.Verified {
t.Errorf("output = %+v, want verified success from the deadline probe", out)
}
env.AssertExpectations(t)
}
// TestVerifyClaimWorkflowRecordsMismatch: a typo'd TXT record is recorded as
// a mismatch with the observed value, so the UI can show
// observed-vs-expected.
func TestVerifyClaimWorkflowRecordsMismatch(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
var lastProbe RecordProbeInput
markedExpired := false
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, &lastProbe)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMismatch, Observed: []string{"member-console-verify=TYPO"}}, nil)
env.OnActivity(acts.MarkClaimExpiredActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { markedExpired = true }).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(10*time.Second)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if lastProbe.TXTState != dommod.ProbeStateMismatch {
t.Errorf("recorded TXT state = %q, want mismatch", lastProbe.TXTState)
}
if len(lastProbe.TXTObserved) != 1 || lastProbe.TXTObserved[0] != "member-console-verify=TYPO" {
t.Errorf("recorded observed = %v, want the typo'd value", lastProbe.TXTObserved)
}
if !markedExpired {
t.Error("MarkClaimExpiredActivity was not called")
}
}
// TestVerifyClaimWorkflowExpires: the record never appears and the deadline
// passes — the claim is marked expired and nothing is created (no mark-active
// stub is registered, so calling it would fail the test).
func TestVerifyClaimWorkflowExpires(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
markedExpired := false
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMissing}, nil)
env.OnActivity(acts.MarkClaimExpiredActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { markedExpired = true }).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(90*time.Second)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if out.Success || out.Verified {
t.Errorf("output = %+v, want unverified failure", out)
}
if !strings.Contains(out.ErrorMessage, "window elapsed") {
t.Errorf("ErrorMessage = %q, want mention of the elapsed window", out.ErrorMessage)
}
if !markedExpired {
t.Error("MarkClaimExpiredActivity was not called")
}
}
// TestVerifyClaimWorkflowContinueAsNewCarriesBackoff: once the server
// suggests continue-as-new the run rolls over instead of growing history,
// carrying the current backoff so the fresh run does not restart at 30s.
func TestVerifyClaimWorkflowContinueAsNewCarriesBackoff(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMissing}, nil)
// Suggest the rollover during the second (60s) wait, so the probe at
// t=90s sees it with the backoff already doubled twice.
env.RegisterDelayedCallback(func() {
env.SetContinueAsNewSuggested(true)
}, 35*time.Second)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
err := env.GetWorkflowError()
var canErr *workflow.ContinueAsNewError
if !errors.As(err, &canErr) {
t.Fatalf("workflow error = %v, want a ContinueAsNewError", err)
}
var next VerifyClaimWorkflowInput
if err := converter.GetDefaultDataConverter().FromPayloads(canErr.Input, &next); err != nil {
t.Fatalf("decode continue-as-new input: %v", err)
}
if next.ClaimID != "claim-1" || next.Root != testRoot || next.Token != "tok" {
t.Errorf("carried input = %+v, want the same claim identity", next)
}
// 2m is the third rung of the 30s → 1m → 2m → 3m ramp and still below the
// cap: the assertion is that the run carries wherever the backoff had got
// to, not that it reached maxPollInterval.
if next.PollInterval != 2*time.Minute {
t.Errorf("carried PollInterval = %v, want the ramp's 2m way-point, not a reset to 30s", next.PollInterval)
}
}
// TestVerifyClaimWorkflowCancelLosesRaceToActivation: a cancel that arrives
// after activation loses. Its store write is guarded on pending, so the
// active row stands; the workflow's side of that guarantee is what this
// asserts — having broken out of the poll loop it exits without touching the
// row again (exactly one mark, no further probe write, no expiry write).
func TestVerifyClaimWorkflowCancelLosesRaceToActivation(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
markedActive := 0
probes := 0
env.OnActivity(acts.CheckConnectActivity, mock.Anything, mock.Anything).
Return(&CheckConnectOutput{State: dommod.ProbeStateMatch}, nil)
env.OnActivity(acts.RecordProbeActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { probes++ }).
Return(nil)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil)
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { markedActive++ }).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if !out.Success || !out.Verified {
t.Errorf("output = %+v, want the activation to stand", out)
}
if markedActive != 1 {
t.Errorf("MarkClaimActiveActivity called %d times, want exactly 1 write to the row", markedActive)
}
if probes != 1 {
t.Errorf("recorded %d probes, want 1 — the loop must stop at the match", probes)
}
}
// TestVerifyClaimWorkflowActivationLosesRaceToCancel: the mirror case — the
// claim went terminal (canceled) between the probe and the mark, so
// MarkClaimActive updates zero rows and returns the non-retryable
// ClaimNotPending error. The workflow reports failure instead of retrying
// against a row it must not reanimate.
func TestVerifyClaimWorkflowActivationLosesRaceToCancel(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
var acts *Activities
attempts := 0
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMatch}, nil)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{State: dommod.ProbeStateMatch}, nil)
env.OnActivity(acts.MarkClaimActiveActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { attempts++ }).
Return(temporal.NewNonRetryableApplicationError("claim is no longer pending", "ClaimNotPending", nil))
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, time.Now().Add(dommod.ExternalClaimWindow)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v", err)
}
var out VerifyClaimWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatalf("get result: %v", err)
}
if out.Success || out.Verified {
t.Errorf("output = %+v, want failure — the terminal row stands", out)
}
if out.ErrorMessage == "" {
t.Error("ErrorMessage is empty, want member-facing guidance")
}
if attempts != 1 {
t.Errorf("MarkClaimActiveActivity attempted %d times, want 1 — the error is non-retryable", attempts)
}
}
// TestChallengeValuePrefixIsStable pins the literal the evidence latch is
// defined against. internal/domains spells the same prefix out in its ledger
// tests — it cannot import this package, the dependency runs the other way —
// so changing it here without changing it there must fail loudly.
func TestChallengeValuePrefixIsStable(t *testing.T) {
if challengeValuePrefix != "member-console-verify=" {
t.Errorf("challengeValuePrefix = %q; internal/domains/ledger_db_test.go hardcodes the old value",
challengeValuePrefix)
}
}
func TestHasChallengeEvidence(t *testing.T) {
tests := []struct {
name string
observed []string
want bool
}{
{"our value with the current token", []string{ChallengeRecordValue("tok")}, true},
{"our prefix with a stale token", []string{ChallengeRecordValue("stale")}, true},
{"our prefix among unrelated records", []string{"v=spf1 -all", ChallengeRecordValue("tok")}, true},
{"surrounding whitespace tolerated", []string{" " + ChallengeRecordValue("tok")}, true},
{"a wildcard TXT in the zone", []string{"v=spf1 -all"}, false},
{"a record that merely mentions the prefix", []string{"note: member-console-verify=tok"}, false},
{"nothing observed", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasChallengeEvidence(tt.observed); got != tt.want {
t.Errorf("hasChallengeEvidence(%v) = %v, want %v", tt.observed, got, tt.want)
}
})
}
}
// TestVerifyClaimWorkflowLatchesEvidenceOnOurPrefix: a record carrying our
// challenge prefix but the wrong token is a `mismatch` for verification and
// evidence for the ledger. Publishing it took write access to the zone, which
// is the whole signal (design D2).
func TestVerifyClaimWorkflowLatchesEvidenceOnOurPrefix(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
var lastProbe RecordProbeInput
stubProbes(env, &CheckConnectOutput{State: dommod.ProbeStateMissing}, &lastProbe)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{
State: dommod.ProbeStateMismatch,
Observed: []string{ChallengeRecordValue("typo")},
Evidence: true,
}, nil)
env.OnActivity(acts.MarkClaimExpiredActivity, mock.Anything, mock.Anything).Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(10*time.Second)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if !lastProbe.Evidence {
t.Errorf("recorded probe = %+v, want evidence for a challenge-prefixed record", lastProbe)
}
}
// TestVerifyClaimWorkflowWithholdsEvidenceFromForeignRecords: the two looser
// predicates are both exploitable and neither may latch — a `mismatch` state
// covers any record at the challenge name (a wildcard TXT in a victim's zone
// would exempt every name a squatter aims at them), and a matching connect
// record is free for any name already pointed at the deployment.
func TestVerifyClaimWorkflowWithholdsEvidenceFromForeignRecords(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
var lastProbe RecordProbeInput
stubProbes(env, &CheckConnectOutput{
State: dommod.ProbeStateMatch,
Observed: []string{testTarget},
}, &lastProbe)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{
State: dommod.ProbeStateMismatch,
Observed: []string{"v=spf1 -all"},
}, nil)
env.OnActivity(acts.MarkClaimExpiredActivity, mock.Anything, mock.Anything).Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(10*time.Second)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if lastProbe.TXTState != dommod.ProbeStateMismatch || lastProbe.ConnectState != dommod.ProbeStateMatch {
t.Fatalf("recorded probe = %+v, want a TXT mismatch beside a connect match", lastProbe)
}
if lastProbe.Evidence {
t.Error("evidence latched on a foreign record beside a matching connect record")
}
}
// TestVerifyClaimWorkflowCarriesEvidenceIntoExpiryAfterALostWrite closes the
// one hole in the latch's recovery story. Every other lost RecordProbeActivity
// write is repaired by the next probe — but the probe at the deadline has no
// next probe, and the expiry mark runs immediately after it. A member who
// published our challenge record and then watched the window elapse must not
// be charged an abandonment because our own write failed, so the verdict rides
// along on the mark.
func TestVerifyClaimWorkflowCarriesEvidenceIntoExpiryAfterALostWrite(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
assertNoChildWorkflow(t, env)
start := time.Now()
env.SetStartTime(start)
var acts *Activities
var expireInput MarkClaimInput
env.OnActivity(acts.CheckConnectActivity, mock.Anything, mock.Anything).
Return(&CheckConnectOutput{State: dommod.ProbeStateMissing}, nil)
env.OnActivity(acts.CheckTXTActivity, mock.Anything, mock.Anything).
Return(&CheckTXTOutput{
State: dommod.ProbeStateMismatch,
Observed: []string{ChallengeRecordValue("typo")},
Evidence: true,
}, nil)
// The write that would have latched never lands — it exhausts its retries.
env.OnActivity(acts.RecordProbeActivity, mock.Anything, mock.Anything).
Return(errors.New("database unavailable"))
env.OnActivity(acts.MarkClaimExpiredActivity, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
if in, ok := args.Get(1).(MarkClaimInput); ok {
expireInput = in
}
}).
Return(nil)
env.ExecuteWorkflow(VerifyClaimWorkflow, verifyInput(testRoot, start.Add(10*time.Second)))
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
}
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow error: %v — a failed probe write must never fail verification", err)
}
if expireInput.ClaimID == "" {
t.Fatal("MarkClaimExpiredActivity was not called")
}
if !expireInput.Evidence {
t.Error("expiry carried no evidence after the final probe's write was lost; the member is charged for our failure")
}
}