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.
835 lines
32 KiB
Go
835 lines
32 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
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")
|
|
}
|
|
}
|