Files
Assistant/cmd/core-runtime/development_gate_test.go
m 0886342031
CI / test (push) Successful in 47s
CI / test (pull_request) Successful in 47s
feat(governance): add development gate evidence transitions
2026-08-23 21:38:46 +03:00

183 lines
7.7 KiB
Go

package main
import (
"context"
"path/filepath"
"testing"
"time"
"git.cin.su/m/Assistant/adapters/developmentexecutor"
"git.cin.su/m/Assistant/contracts/work"
)
type developmentGateStoreStub struct {
gate work.DevelopmentGate
saved bool
expected int64
}
func (s *developmentGateStoreStub) LoadDevelopmentGate(context.Context, string) (work.DevelopmentGate, error) {
return s.gate, nil
}
func (s *developmentGateStoreStub) SaveDevelopmentGate(_ context.Context, gate work.DevelopmentGate, expected int64) error {
s.gate = gate
s.saved = true
s.expected = expected
return nil
}
type developmentCommandRunnerStub struct {
exitCodes []int
commands int
}
func (r *developmentCommandRunnerStub) Run(context.Context, string, string, []string) int {
code := 0
if r.commands < len(r.exitCodes) {
code = r.exitCodes[r.commands]
}
r.commands++
return code
}
func validDevelopmentVerificationPlan(t *testing.T, worktreeID string) developmentexecutor.Plan {
t.Helper()
worktree, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
return developmentexecutor.Plan{
Worktree: worktree,
WorktreeID: worktreeID,
BaseRepoRevision: "base-revision",
ProtectedMainAccess: "DENIED",
ProductionAccess: "DENIED",
SecretAccess: "DENIED",
Checks: []developmentexecutor.Check{{Kind: developmentexecutor.CheckGitDiffCheck}},
}
}
func isolatedDevelopmentGate() work.DevelopmentGate {
return work.DevelopmentGate{
ID: "gate-1",
Revision: 4,
Stage: work.DevelopmentIsolated,
EvidenceRefs: []string{"evidence-1"},
ProposalRef: "proposal-1",
TaskPacketRef: "task-1",
WorktreeRef: "sandbox-1",
ProducerRunRefs: []work.AgentRunID{"producer-1"},
VerifierRunRefs: nil,
VerificationEvidence: "",
}
}
func TestVerifyDevelopmentGatePersistsVerifiedWithIndependentEvidence(t *testing.T) {
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
runner := &developmentCommandRunnerStub{exitCodes: []int{0}}
gate, result, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
GateID: "gate-1",
ExpectedRevision: 4,
VerifierRunID: "verifier-1",
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
}, time.UnixMilli(20), runner)
if err != nil {
t.Fatal(err)
}
if !result.Passed || gate.Stage != work.DevelopmentVerified || gate.Revision != 5 {
t.Fatalf("verification did not advance gate: gate=%#v result=%#v", gate, result)
}
if !store.saved || store.expected != 4 || gate.VerificationEvidence == "" {
t.Fatalf("verification was not durably persisted: store=%#v gate=%#v", store, gate)
}
}
func TestVerifyDevelopmentGateLeavesIsolatedOnFailedChecks(t *testing.T) {
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
gate, result, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
GateID: "gate-1",
ExpectedRevision: 4,
VerifierRunID: "verifier-1",
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
}, time.UnixMilli(20), &developmentCommandRunnerStub{exitCodes: []int{1}})
if err != errDevelopmentChecksFailed || result.Passed || gate.Stage != work.DevelopmentIsolated || store.saved {
t.Fatalf("failed checks changed gate unexpectedly: gate=%#v result=%#v err=%v store=%#v", gate, result, err, store)
}
}
func TestVerifyDevelopmentGateRejectsProducerAsVerifier(t *testing.T) {
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
_, _, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
GateID: "gate-1",
ExpectedRevision: 4,
VerifierRunID: "producer-1",
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
}, time.UnixMilli(20), &developmentCommandRunnerStub{exitCodes: []int{0}})
if err != errDevelopmentVerifierNotIndependent {
t.Fatalf("producer was accepted as verifier: %v", err)
}
}
func TestRecordDevelopmentGateEvidenceAdvancesCanaryAndPromotion(t *testing.T) {
store := &developmentGateStoreStub{gate: work.DevelopmentGate{
ID: "gate-1", Revision: 6, Stage: work.DevelopmentVerified,
EvidenceRefs: []string{"evidence-1"}, ProposalRef: "proposal-1", TaskPacketRef: "task-1", WorktreeRef: "sandbox-1",
ProducerRunRefs: []work.AgentRunID{"producer-1"}, VerifierRunRefs: []work.AgentRunID{"verifier-1"}, VerificationEvidence: "verification-1",
}}
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 6, NextStage: work.DevelopmentCanary, EvidenceRef: "canary-1"}, time.UnixMilli(30))
if err != nil || gate.Stage != work.DevelopmentCanary || gate.Revision != 7 || gate.CanaryEvidence != "canary-1" {
t.Fatalf("canary transition failed: gate=%#v err=%v", gate, err)
}
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 7, NextStage: work.DevelopmentPromoted, EvidenceRef: "promotion-1"}, time.UnixMilli(31))
if err != nil || gate.Stage != work.DevelopmentPromoted || gate.Revision != 8 || gate.PromotionRef != "promotion-1" {
t.Fatalf("promotion transition failed: gate=%#v err=%v", gate, err)
}
}
func TestRecordDevelopmentGateEvidenceSupportsCanaryRollbackAndRejectsMissingEvidence(t *testing.T) {
store := &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-1", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack, EvidenceRef: "rollback-1"}, time.UnixMilli(40))
if err != nil || gate.Stage != work.DevelopmentRolledBack || gate.RollbackRef != "rollback-1" {
t.Fatalf("rollback transition failed: gate=%#v err=%v", gate, err)
}
store = &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-2", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-2", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack}, time.UnixMilli(40))
if err != errDevelopmentEvidenceRequired || gate.Stage != work.DevelopmentCanary || store.saved {
t.Fatalf("missing rollback evidence changed gate: gate=%#v err=%v store=%#v", gate, err, store)
}
}
func TestDevelopmentGateControllerRequiresSequentialVerifiedEvidence(t *testing.T) {
now := time.UnixMilli(10)
gate, err := prepareDevelopmentGate(work.DevelopmentGate{ID: "gate-1", Revision: 1, Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"evidence-1"}}, now)
if err != nil {
t.Fatal(err)
}
gate.ProposalRef = "proposal-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentProposalCreated, now)
if err != nil || gate.Revision != 2 {
t.Fatalf("proposal transition failed: %#v %v", gate, err)
}
gate.TaskPacketRef = "task-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentTaskAdmitted, now)
if err != nil {
t.Fatal(err)
}
gate.WorktreeRef = "isolated-worktree"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentIsolated, now)
if err != nil {
t.Fatal(err)
}
gate.ProducerRunRefs = []work.AgentRunID{"producer"}
gate.VerifierRunRefs = []work.AgentRunID{"verifier"}
gate.VerificationEvidence = "verification-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
if err != nil {
t.Fatal(err)
}
if _, err := advanceDevelopmentGate(gate, work.DevelopmentPromoted, now); err == nil {
t.Fatal("promotion bypassed canary")
}
}