Add atomic pending verification and activation operations
6 files changed,
+386
−3
internal/operator/directory.go+6 −0internal/operator/directory_test.go+1 −1internal/operator/protocol.go+1 −1internal/operator/protocol_test.go+1 −1internal/operator/verification.go+223 −0internal/operator/verification_test.go+154 −0
diff --git a/internal/operator/directory.go b/internal/operator/directory.go| index de80ebb..c0e629c 100644 |
| --- a/internal/operator/directory.go |
| +++ b/internal/operator/directory.go |
| @@ -51,6 +51,8 @@ type ProvisionInput struct { |
| } |
| type Result struct { |
| + Attempt string `json:"attempt_id,omitempty"` |
| + Expires int64 `json:"expires_at,omitempty"` |
| Version int `json:"version"` |
| OperationID string `json:"operation_id"` |
| Outcome string `json:"outcome"` |
| @@ -128,6 +130,10 @@ func (d *Directory) Execute(ctx context.Context, raw []byte) ([]byte, error) { |
| return d.seed(ctx, c, r) |
| case "directory.provision-consumer": |
| return d.provision(ctx, c, r) |
| + case "directory.verify-consumer": |
| + return d.verifyConsumer(ctx, c, r) |
| + case "directory.activate-consumer": |
| + return d.activate(ctx, c, r) |
| default: |
| return nil, ErrRefused |
| } |
diff --git a/internal/operator/directory_test.go b/internal/operator/directory_test.go| index 6fb407e..33821db 100644 |
| --- a/internal/operator/directory_test.go |
| +++ b/internal/operator/directory_test.go |
| @@ -26,7 +26,7 @@ func newDirectoryFixture(t *testing.T) *directoryFixture { |
| t.Helper() |
| f := newEngineFixture(t) |
| p := f.engine.verifier.pins |
| - p.Actions = []string{"directory.initialize", "directory.seed-team", "directory.provision-consumer"} |
| + p.Actions = []string{"directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer"} |
| v, e := NewVerifier(p) |
| if e != nil { |
| t.Fatal(e) |
diff --git a/internal/operator/protocol.go b/internal/operator/protocol.go| index d793d7a..37e1703 100644 |
| --- a/internal/operator/protocol.go |
| +++ b/internal/operator/protocol.go |
| @@ -133,7 +133,7 @@ func allowedAction(role, a string) bool { |
| } |
| if role == "directory" { |
| switch a { |
| - case "directory.initialize", "directory.seed-team", "directory.provision-consumer": |
| + case "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer": |
| return true |
| } |
| } |
diff --git a/internal/operator/protocol_test.go b/internal/operator/protocol_test.go| index b4bf95e..ea0117b 100644 |
| --- a/internal/operator/protocol_test.go |
| +++ b/internal/operator/protocol_test.go |
| @@ -131,7 +131,7 @@ func TestConfigurationAndUnsignedActionsRefuse(t *testing.T) { |
| t.Fatal("origin accepted", o) |
| } |
| } |
| - for _, action := range []string{"directory.activate-consumer", "directory.verify-consumer", "directory.restore", "raw.sql"} { |
| + for _, action := range []string{"directory.restore", "raw.sql"} { |
| p := v.pins |
| p.Actions = []string{action} |
| if _, e := NewVerifier(p); e != ErrRefused { |
diff --git a/internal/operator/verification.go b/internal/operator/verification.go| new file mode 100644 |
| index 0000000..b7e9aea |
| --- /dev/null |
| +++ b/internal/operator/verification.go |
| @@ -0,0 +1,223 @@ |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "net/url" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| +) |
| + |
| +type VerificationInput struct { |
| + InputBase |
| + Consumer string `json:"consumer"` |
| + TeamSQID string `json:"team_sqid"` |
| + App string `json:"app"` |
| + Audience string `json:"audience"` |
| + Generation string `json:"generation"` |
| + RecordSequence int64 `json:"record_sequence"` |
| + RecordDigest string `json:"record_digest"` |
| + Epoch int64 `json:"expected_epoch"` |
| + Owner string `json:"owner_ref"` |
| + Issued int64 `json:"issued_at"` |
| + Expires int64 `json:"expires_at"` |
| +} |
| +type ActivationEvidence struct { |
| + Version int `json:"version"` |
| + Policy string `json:"policy"` |
| + Attempt string `json:"attempt_id"` |
| + Consumer string `json:"consumer"` |
| + TeamSQID string `json:"team_sqid"` |
| + App string `json:"app"` |
| + Audience string `json:"audience"` |
| + Generation string `json:"generation"` |
| + RecordSequence int64 `json:"record_sequence"` |
| + RecordDigest string `json:"record_digest"` |
| + Epoch int64 `json:"epoch"` |
| + Owner string `json:"owner_ref"` |
| + AppVersion string `json:"app_version"` |
| + ArtifactDigest string `json:"artifact_digest"` |
| + EnvironmentBinding string `json:"environment_binding"` |
| + ManifestDigest string `json:"credential_manifest_digest"` |
| + AliasHost string `json:"alias_host"` |
| + Started int64 `json:"started_at"` |
| + Completed int64 `json:"completed_at"` |
| +} |
| +type ActivationInput struct { |
| + InputBase |
| + Evidence ActivationEvidence `json:"evidence"` |
| +} |
| + |
| +type pendingBinding struct { |
| + teamID, epoch int64 |
| + label string |
| + sequence int64 |
| + generation string |
| + digest []byte |
| +} |
| + |
| +func (d *Directory) pending(ctx context.Context, c *sql.Conn, consumer, team, app, audience, gen string, sequence int64, recordDigest, owner string, epoch int64) (pendingBinding, error) { |
| + fail := func() (pendingBinding, error) { return pendingBinding{}, ErrRefused } |
| + expected, ok := canonical(recordDigest, 32) |
| + if !atom(consumer, 64) || !d.apps[app] || !Origin(audience) || !CanonicalToken(gen) || sequence <= 0 || !ok || !PersonRef(owner) || epoch <= 0 { |
| + return fail() |
| + } |
| + var b pendingBinding |
| + var storedTeam, storedApp, storedOrigin, state string |
| + var enabled int |
| + err := c.QueryRowContext(ctx, `SELECT t.id,t.sqid,t.label,t.roster_epoch,i.app,i.origin,i.state,c.enabled |
| + FROM roster_consumers c JOIN team_instances i ON i.consumer_id=c.consumer_id AND i.team_id=c.team_id AND i.app=c.app AND i.origin=c.origin |
| + JOIN teams t ON t.id=c.team_id WHERE c.consumer_id=?`, consumer).Scan(&b.teamID, &storedTeam, &b.label, &b.epoch, &storedApp, &storedOrigin, &state, &enabled) |
| + if err != nil { |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return fail() |
| + } |
| + return pendingBinding{}, ErrUnavailable |
| + } |
| + if enabled != 1 || state != "pending" || storedTeam != team || storedApp != app || storedOrigin != audience || b.epoch != epoch { |
| + return fail() |
| + } |
| + if err = generation(ctx, c, gen); err != nil { |
| + return pendingBinding{}, err |
| + } |
| + var receiptSequence int64 |
| + var receiptGeneration, receiptConsumer, kind string |
| + var receiptDigest []byte |
| + err = c.QueryRowContext(ctx, `SELECT p.sequence,p.generation,p.record_digest,r.sequence,r.generation,r.record_digest,r.consumer_id,r.operation |
| + FROM directory_operator_consumer_records p JOIN roster_operator_receipts r ON r.operation_id=p.operation_id WHERE p.consumer_id=?`, consumer).Scan(&b.sequence, &b.generation, &b.digest, &receiptSequence, &receiptGeneration, &receiptDigest, &receiptConsumer, &kind) |
| + if err != nil { |
| + return pendingBinding{}, ErrUnavailable |
| + } |
| + if b.sequence != sequence || b.generation != gen || !bytes.Equal(b.digest, expected) || receiptSequence != b.sequence || receiptGeneration != b.generation || !bytes.Equal(receiptDigest, b.digest) || receiptConsumer != consumer || kind != "provision" { |
| + return fail() |
| + } |
| + var found int |
| + if err = c.QueryRowContext(ctx, "SELECT 1 FROM memberships WHERE team_id=? AND person_ref=? AND role='owner'", b.teamID, owner).Scan(&found); err != nil { |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return fail() |
| + } |
| + return pendingBinding{}, ErrUnavailable |
| + } |
| + var currentTokens int |
| + if err = c.QueryRowContext(ctx, "SELECT count(*) FROM roster_tokens WHERE consumer_id=? AND slot='current' AND expires_at IS NULL AND issued_at<=?", consumer, d.engine.now().Unix()).Scan(¤tTokens); err != nil { |
| + return pendingBinding{}, ErrUnavailable |
| + } |
| + if currentTokens != 1 { |
| + return fail() |
| + } |
| + if !d.policy.ValidateRoute(directoryRoute(b, team, app, audience, consumer)) { |
| + return fail() |
| + } |
| + return b, nil |
| +} |
| +func (d *Directory) verifyConsumer(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { |
| + var in VerificationInput |
| + if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial { |
| + return nil, ErrRefused |
| + } |
| + now := d.engine.now().Unix() |
| + if in.Issued <= 0 || in.Issued > now || in.Expires <= now || in.Expires <= in.Issued || in.Expires-in.Issued > 600 { |
| + return nil, ErrRefused |
| + } |
| + binding, e := d.pending(ctx, c, in.Consumer, in.TeamSQID, in.App, in.Audience, in.Generation, in.RecordSequence, in.RecordDigest, in.Owner, in.Epoch) |
| + if e != nil { |
| + return nil, e |
| + } |
| + var old, oldConsumer string |
| + var issued, expires int64 |
| + e = c.QueryRowContext(ctx, "SELECT a.operation_id,a.consumer_id,a.issued_at,a.expires_at FROM verification_active v JOIN verification_attempts a ON a.operation_id=v.operation_id WHERE v.consumer_id=?", in.Consumer).Scan(&old, &oldConsumer, &issued, &expires) |
| + if e == nil { |
| + if oldConsumer != in.Consumer || issued <= 0 || issued > now || expires <= issued || expires-issued > 600 || expires > now { |
| + return nil, ErrRefused |
| + } |
| + if _, e = c.ExecContext(ctx, "DELETE FROM verification_active WHERE consumer_id=? AND operation_id=?", in.Consumer, old); e != nil { |
| + return nil, e |
| + } |
| + if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'expired',?)", old, r.authorization.OperationID, now); e != nil { |
| + return nil, e |
| + } |
| + } else if !errors.Is(e, sql.ErrNoRows) { |
| + return nil, ErrUnavailable |
| + } |
| + hash, ok := canonical(r.Head(), 32) |
| + if !ok { |
| + return nil, ErrUnavailable |
| + } |
| + _, e = c.ExecContext(ctx, "INSERT INTO verification_attempts VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", r.authorization.OperationID, in.Consumer, binding.teamID, in.App, in.Audience, in.Generation, in.RecordSequence, binding.digest, in.Epoch, in.Owner, in.Issued, in.Expires, hash) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if _, e = c.ExecContext(ctx, "INSERT INTO verification_active VALUES(?,?)", in.Consumer, r.authorization.OperationID); e != nil { |
| + return nil, e |
| + } |
| + if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'opened',?)", r.authorization.OperationID, r.authorization.OperationID, now); e != nil { |
| + return nil, e |
| + } |
| + result := resultFor(r) |
| + result.Attempt = r.authorization.OperationID |
| + result.Expires = in.Expires |
| + return encode(result) |
| +} |
| +func version(s string) bool { |
| + if len(s) != 40 { |
| + return false |
| + } |
| + for _, c := range s { |
| + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func (d *Directory) activate(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { |
| + var in ActivationInput |
| + if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial { |
| + return nil, ErrRefused |
| + } |
| + p := in.Evidence |
| + now := d.engine.now().Unix() |
| + u, e := url.Parse(p.Audience) |
| + if e != nil || p.Version != 1 || p.Policy != "oficina/team-activation/v1" || !CanonicalToken(p.Attempt) || !version(p.AppVersion) || !CanonicalToken(p.ArtifactDigest) || !CanonicalToken(p.EnvironmentBinding) || !CanonicalToken(p.ManifestDigest) || p.AliasHost != u.Host || p.Started <= 0 || p.Completed < p.Started || p.Completed > now || now-p.Completed >= 300 { |
| + return nil, ErrRefused |
| + } |
| + binding, e := d.pending(ctx, c, p.Consumer, p.TeamSQID, p.App, p.Audience, p.Generation, p.RecordSequence, p.RecordDigest, p.Owner, p.Epoch) |
| + if e != nil { |
| + return nil, e |
| + } |
| + var consumer, app, origin, gen, owner string |
| + var teamID, sequence, epoch, issued, expires int64 |
| + var recordDigest []byte |
| + e = c.QueryRowContext(ctx, `SELECT a.consumer_id,a.team_id,a.app,a.origin,a.generation,a.record_sequence,a.record_digest,a.expected_epoch,a.owner_ref,a.issued_at,a.expires_at |
| + FROM verification_active v JOIN verification_attempts a ON a.operation_id=v.operation_id WHERE v.consumer_id=? AND a.operation_id=?`, p.Consumer, p.Attempt).Scan(&consumer, &teamID, &app, &origin, &gen, &sequence, &recordDigest, &epoch, &owner, &issued, &expires) |
| + if e != nil { |
| + if errors.Is(e, sql.ErrNoRows) { |
| + return nil, ErrRefused |
| + } |
| + return nil, ErrUnavailable |
| + } |
| + if consumer != p.Consumer || teamID != binding.teamID || app != p.App || origin != p.Audience || gen != p.Generation || sequence != p.RecordSequence || !bytes.Equal(recordDigest, binding.digest) || epoch != p.Epoch || owner != p.Owner || issued <= 0 || issued > now || expires <= now || expires <= issued || expires-issued > 600 || p.Started < issued || p.Completed >= expires { |
| + return nil, ErrRefused |
| + } |
| + if _, e = c.ExecContext(ctx, "UPDATE team_instances SET state='live' WHERE consumer_id=? AND state='pending'", p.Consumer); e != nil { |
| + return nil, e |
| + } |
| + if _, e = c.ExecContext(ctx, "DELETE FROM verification_active WHERE consumer_id=? AND operation_id=?", p.Consumer, p.Attempt); e != nil { |
| + return nil, e |
| + } |
| + if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'activated',?)", p.Attempt, r.authorization.OperationID, now); e != nil { |
| + return nil, e |
| + } |
| + result := resultFor(r) |
| + result.Attempt = p.Attempt |
| + result.Generation = p.Generation |
| + result.TeamID = binding.teamID |
| + result.TeamSQID = p.TeamSQID |
| + result.Epoch = p.Epoch |
| + return encode(result) |
| +} |
| + |
| +func directoryRoute(b pendingBinding, team, app, audience, consumer string) directorydata.Route { |
| + return directorydata.Route{TeamID: b.teamID, TeamSQID: team, Label: b.label, App: app, Origin: audience, ConsumerID: consumer} |
| +} |
diff --git a/internal/operator/verification_test.go b/internal/operator/verification_test.go| new file mode 100644 |
| index 0000000..bcb9262 |
| --- /dev/null |
| +++ b/internal/operator/verification_test.go |
| @@ -0,0 +1,154 @@ |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +func pendingFixture(t *testing.T) (*directoryFixture, VerificationInput) { |
| + t.Helper() |
| + f := newDirectoryFixture(t) |
| + f.initialize(t) |
| + if _, err := f.seed(t, 1, "alpha"); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| + return ProvisionInput{b, token(10), "Uk", "docs", "alpha-docs", "https://alpha.docs.oficina.works", 0} |
| + }) |
| + if _, err := f.directory.Execute(context.Background(), raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + var digest []byte |
| + if err := f.db.QueryRow("SELECT record_digest FROM directory_operator_consumer_records").Scan(&digest); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f, VerificationInput{Consumer: "alpha-docs", TeamSQID: "Uk", App: "docs", Audience: "https://alpha.docs.oficina.works", Generation: token(10), RecordSequence: 1, RecordDigest: base64.RawURLEncoding.EncodeToString(digest), Epoch: 2, Owner: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", Issued: f.now.Unix(), Expires: f.now.Unix() + 600} |
| +} |
| +func openWindow(t *testing.T, f *directoryFixture, in VerificationInput) ([]byte, []byte, Result) { |
| + t.Helper() |
| + raw := f.input(t, "directory.verify-consumer", func(b InputBase) any { in.InputBase = b; return in }) |
| + out, err := f.directory.Execute(context.Background(), raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var result Result |
| + if err = json.Unmarshal(out, &result); err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw, out, result |
| +} |
| +func activation(in VerificationInput, attempt string) ActivationEvidence { |
| + return ActivationEvidence{Version: 1, Policy: "oficina/team-activation/v1", Attempt: attempt, Consumer: in.Consumer, TeamSQID: in.TeamSQID, App: in.App, Audience: in.Audience, Generation: in.Generation, RecordSequence: in.RecordSequence, RecordDigest: in.RecordDigest, Epoch: in.Epoch, Owner: in.Owner, AppVersion: strings.Repeat("a", 40), ArtifactDigest: token(80), EnvironmentBinding: token(81), ManifestDigest: token(82), AliasHost: "alpha.docs.oficina.works", Started: in.Issued, Completed: in.Issued} |
| +} |
| +func TestVerificationAbsoluteExpiryAndExactReplay(t *testing.T) { |
| + f, in := pendingFixture(t) |
| + raw, out, result := openWindow(t, f, in) |
| + *f.now = f.now.Add(600 * time.Second) |
| + replay, err := f.directory.Execute(context.Background(), raw) |
| + if err != nil || !bytes.Equal(replay, out) || result.Expires != in.Expires { |
| + t.Fatal("expired replay changed window", err) |
| + } |
| + in.Issued = f.now.Unix() |
| + in.Expires = in.Issued + 600 |
| + _, _, next := openWindow(t, f, in) |
| + if next.Attempt == result.Attempt || countRows(t, f.db, "verification_attempts") != 2 || countRows(t, f.db, "verification_active") != 1 || countRows(t, f.db, "verification_outcomes") != 3 { |
| + t.Fatal("replacement lost immutable history") |
| + } |
| + raw = f.input(t, "directory.verify-consumer", func(b InputBase) any { in.InputBase = b; return in }) |
| + if _, err = f.directory.Execute(context.Background(), raw); err != ErrRefused { |
| + t.Fatal("overlap renewed window", err) |
| + } |
| +} |
| +func TestVerificationRejectsWrongScopeBeforeStanding(t *testing.T) { |
| + mutations := map[string]func(*VerificationInput){ |
| + "long": func(v *VerificationInput) { v.Expires++ }, "expired": func(v *VerificationInput) { v.Expires = v.Issued }, "future": func(v *VerificationInput) { v.Issued++; v.Expires++ }, |
| + "epoch": func(v *VerificationInput) { v.Epoch++ }, "owner": func(v *VerificationInput) { v.Owner = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb" }, "record": func(v *VerificationInput) { v.RecordDigest = token(99) }, "generation": func(v *VerificationInput) { v.Generation = token(99) }, "audience": func(v *VerificationInput) { v.Audience = "https://beta.docs.oficina.works" }, |
| + } |
| + for name, mutate := range mutations { |
| + t.Run(name, func(t *testing.T) { |
| + f, in := pendingFixture(t) |
| + mutate(&in) |
| + raw := f.input(t, "directory.verify-consumer", func(b InputBase) any { in.InputBase = b; return in }) |
| + if _, err := f.directory.Execute(context.Background(), raw); err != ErrRefused { |
| + t.Fatal(err) |
| + } |
| + if countRows(t, f.db, "verification_attempts") != 0 || countRows(t, f.db, "verification_active") != 0 { |
| + t.Fatal("rejected attempt granted standing") |
| + } |
| + }) |
| + } |
| +} |
| +func TestActivationExactEvidenceAndAtomicReplay(t *testing.T) { |
| + f, in := pendingFixture(t) |
| + _, _, window := openWindow(t, f, in) |
| + evidence := activation(in, window.Attempt) |
| + raw := f.input(t, "directory.activate-consumer", func(b InputBase) any { return ActivationInput{b, evidence} }) |
| + out, err := f.directory.Execute(context.Background(), raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + replay, err := f.directory.Execute(context.Background(), raw) |
| + if err != nil || !bytes.Equal(out, replay) { |
| + t.Fatal("activation replay", err) |
| + } |
| + var state string |
| + if err = f.db.QueryRow("SELECT state FROM team_instances").Scan(&state); err != nil || state != "live" || countRows(t, f.db, "verification_active") != 0 || countRows(t, f.db, "verification_outcomes") != 2 { |
| + t.Fatal("incomplete activation", err) |
| + } |
| +} |
| +func TestActivationRejectsChangedStateAndEvidence(t *testing.T) { |
| + cases := map[string]func(*testing.T, *directoryFixture, *ActivationEvidence){ |
| + "token": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { |
| + if _, e := f.db.Exec("DELETE FROM roster_tokens"); e != nil { |
| + t.Fatal(e) |
| + } |
| + }, |
| + "alias": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { |
| + p.AliasHost = "beta.docs.oficina.works" |
| + }, |
| + "attempt": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { p.Attempt = token(95) }, |
| + "version": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { p.AppVersion = "latest" }, |
| + "future": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { p.Completed++ }, |
| + "expired": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { |
| + *f.now = f.now.Add(600 * time.Second) |
| + p.Completed = f.now.Unix() |
| + }, |
| + "epoch": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { |
| + if _, e := f.db.Exec("INSERT INTO memberships(team_id,person_ref,role) VALUES(1,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')"); e != nil { |
| + t.Fatal(e) |
| + } |
| + }, |
| + "owner": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { |
| + if _, e := f.db.Exec("DELETE FROM memberships"); e != nil { |
| + t.Fatal(e) |
| + } |
| + }, |
| + "pointer": func(t *testing.T, f *directoryFixture, p *ActivationEvidence) { |
| + if _, e := f.db.Exec("UPDATE directory_operator_consumer_records SET sequence=2"); e != nil { |
| + t.Fatal(e) |
| + } |
| + }, |
| + } |
| + for name, mutate := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + f, in := pendingFixture(t) |
| + _, _, window := openWindow(t, f, in) |
| + p := activation(in, window.Attempt) |
| + mutate(t, f, &p) |
| + raw := f.input(t, "directory.activate-consumer", func(b InputBase) any { return ActivationInput{b, p} }) |
| + if _, err := f.directory.Execute(context.Background(), raw); err != ErrRefused { |
| + t.Fatal(err) |
| + } |
| + var state string |
| + if err := f.db.QueryRow("SELECT state FROM team_instances").Scan(&state); err != nil || state != "pending" || countRows(t, f.db, "verification_active") != 1 || countRows(t, f.db, "verification_outcomes") != 1 { |
| + t.Fatal("failed activation mutated", err) |
| + } |
| + }) |
| + } |
| +} |