214 lines
6.6 KiB
Go
214 lines
6.6 KiB
Go
|
|
package policy
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/intent"
|
||
|
|
)
|
||
|
|
|
||
|
|
func passing() contract.Revision {
|
||
|
|
pc := contract.RevisionPolicyPolicyCheckPassed
|
||
|
|
return contract.Revision{
|
||
|
|
ID: "R-2",
|
||
|
|
Interface: "hall-publishing",
|
||
|
|
State: contract.RevisionStateCandidate,
|
||
|
|
Policy: contract.RevisionPolicy{
|
||
|
|
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
|
||
|
|
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
|
||
|
|
PolicyCheck: &pc,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func human() *contract.Actor {
|
||
|
|
return &contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
|
||
|
|
}
|
||
|
|
|
||
|
|
func baseInput() Input {
|
||
|
|
return Input{
|
||
|
|
Descriptor: passing(),
|
||
|
|
GoverningMode: intent.ModeAdvisory,
|
||
|
|
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
||
|
|
ComplexityDelta: 0.2,
|
||
|
|
RequestedTrafficShare: 0.10,
|
||
|
|
Approved: true,
|
||
|
|
ApprovedBy: human(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateAllowsAConformingCandidate(t *testing.T) {
|
||
|
|
d := NewGate(DefaultLimits()).Evaluate(baseInput())
|
||
|
|
if !d.Allowed {
|
||
|
|
t.Fatalf("conforming candidate rejected: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateRefusesUnverifiedSecurity(t *testing.T) {
|
||
|
|
in := baseInput()
|
||
|
|
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckPending
|
||
|
|
|
||
|
|
d := NewGate(DefaultLimits()).Evaluate(in)
|
||
|
|
if d.Allowed {
|
||
|
|
t.Fatal("a candidate with a pending security check was allowed")
|
||
|
|
}
|
||
|
|
if !mentions(d.Reasons, "security check") {
|
||
|
|
t.Errorf("reasons do not name the security check: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateRefusesBreakingChange(t *testing.T) {
|
||
|
|
in := baseInput()
|
||
|
|
in.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
|
||
|
|
|
||
|
|
if d := NewGate(DefaultLimits()).Evaluate(in); d.Allowed {
|
||
|
|
t.Fatal("a breaking change passed the default gate")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateEnforcesAuthorityMode(t *testing.T) {
|
||
|
|
limits := DefaultLimits()
|
||
|
|
limits.RequiredMode = intent.ModeExperimental
|
||
|
|
|
||
|
|
in := baseInput()
|
||
|
|
in.GoverningMode = intent.ModeAdvisory
|
||
|
|
|
||
|
|
d := NewGate(limits).Evaluate(in)
|
||
|
|
if d.Allowed {
|
||
|
|
t.Fatal("promotion requiring FLUID-4 was allowed under a FLUID-2 intent")
|
||
|
|
}
|
||
|
|
if !mentions(d.Reasons, "FLUID-2") {
|
||
|
|
t.Errorf("reasons do not name the governing mode: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
|
||
|
|
in.GoverningMode = intent.ModeEvolutionary
|
||
|
|
if d := NewGate(limits).Evaluate(in); !d.Allowed {
|
||
|
|
t.Errorf("FLUID-6 should satisfy a FLUID-4 requirement: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateEnforcesComplexityBudget(t *testing.T) {
|
||
|
|
// FluidAPIStandards.md section 23: a candidate may be rejected even when it
|
||
|
|
// increases local utility.
|
||
|
|
in := baseInput()
|
||
|
|
in.ComplexityDelta = 9.0
|
||
|
|
|
||
|
|
d := NewGate(DefaultLimits()).Evaluate(in)
|
||
|
|
if d.Allowed {
|
||
|
|
t.Fatal("a candidate far over the complexity budget was allowed")
|
||
|
|
}
|
||
|
|
if !mentions(d.Reasons, "complexity") {
|
||
|
|
t.Errorf("reasons do not name complexity: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateTakesTheTighterTrafficCeiling(t *testing.T) {
|
||
|
|
limits := DefaultLimits()
|
||
|
|
limits.MaxTrafficShare = 0.50
|
||
|
|
|
||
|
|
in := baseInput()
|
||
|
|
share := contract.UnitInterval(0.10)
|
||
|
|
in.Descriptor.Routing = &contract.RevisionRouting{MaxTrafficShare: &share}
|
||
|
|
in.RequestedTrafficShare = 0.30
|
||
|
|
|
||
|
|
// The descriptor restricts itself below the gate's ceiling; the tighter of
|
||
|
|
// the two must win, or a descriptor's self-restriction would be advisory.
|
||
|
|
if d := NewGate(limits).Evaluate(in); d.Allowed {
|
||
|
|
t.Fatal("requested share exceeded the descriptor's own ceiling but was allowed")
|
||
|
|
}
|
||
|
|
|
||
|
|
// A descriptor must not be able to widen past the gate.
|
||
|
|
wide := contract.UnitInterval(0.99)
|
||
|
|
in.Descriptor.Routing = &contract.RevisionRouting{MaxTrafficShare: &wide}
|
||
|
|
in.RequestedTrafficShare = 0.80
|
||
|
|
if d := NewGate(limits).Evaluate(in); d.Allowed {
|
||
|
|
t.Fatal("a descriptor widened its own exposure past the gate ceiling")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateRequiresApproval(t *testing.T) {
|
||
|
|
in := baseInput()
|
||
|
|
in.Approved = false
|
||
|
|
in.ApprovedBy = nil
|
||
|
|
|
||
|
|
if d := NewGate(DefaultLimits()).Evaluate(in); d.Allowed {
|
||
|
|
t.Fatal("an unapproved candidate passed a gate that requires approval")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestDaimonCannotSelfApproveBelowBoundedAutonomy defends Blueprint 28.1:
|
||
|
|
// generation authority is not promotion authority. A candidate a Daimon both
|
||
|
|
// produced and approved has had no independent check.
|
||
|
|
func TestDaimonCannotSelfApproveBelowBoundedAutonomy(t *testing.T) {
|
||
|
|
in := baseInput()
|
||
|
|
in.ApprovedBy = &contract.Actor{Type: contract.ActorTypeDaimon, ID: "fluid-daimon/hall"}
|
||
|
|
|
||
|
|
d := NewGate(DefaultLimits()).Evaluate(in)
|
||
|
|
if d.Allowed {
|
||
|
|
t.Fatal("a daimon authorized its own promotion at FLUID-2")
|
||
|
|
}
|
||
|
|
if !mentions(d.Reasons, "daimon") {
|
||
|
|
t.Errorf("reasons do not name the daimon authorization: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
|
||
|
|
// At bounded autonomy, with the policy demanding it, this is legitimate.
|
||
|
|
limits := DefaultLimits()
|
||
|
|
limits.RequiredMode = intent.ModeBoundedAutonomous
|
||
|
|
in.GoverningMode = intent.ModeBoundedAutonomous
|
||
|
|
if d := NewGate(limits).Evaluate(in); !d.Allowed {
|
||
|
|
t.Errorf("daimon authorization refused at FLUID-5: %v", d.Reasons)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateReportsEveryFailure(t *testing.T) {
|
||
|
|
// A caller fixing one rejection only to hit the next learns less than one
|
||
|
|
// told everything at once, and the audit trail wants the whole list.
|
||
|
|
in := baseInput()
|
||
|
|
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
|
||
|
|
in.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
|
||
|
|
in.ComplexityDelta = 50
|
||
|
|
in.Approved = false
|
||
|
|
in.ApprovedBy = nil
|
||
|
|
in.AdaptationClasses = []contract.AdaptationClass{contract.AdaptationClassContract}
|
||
|
|
|
||
|
|
d := NewGate(DefaultLimits()).Evaluate(in)
|
||
|
|
if d.Allowed {
|
||
|
|
t.Fatal("a candidate failing five gates was allowed")
|
||
|
|
}
|
||
|
|
if len(d.Reasons) < 5 {
|
||
|
|
t.Errorf("expected at least five reasons, got %d: %v", len(d.Reasons), d.Reasons)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGateIsDeterministic(t *testing.T) {
|
||
|
|
// The gate must be a pure function: same input, same verdict, same reasons
|
||
|
|
// in the same order. A gate whose output varies cannot be audited.
|
||
|
|
in := baseInput()
|
||
|
|
in.Descriptor.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
|
||
|
|
in.AdaptationClasses = []contract.AdaptationClass{contract.AdaptationClassContract}
|
||
|
|
|
||
|
|
g := NewGate(DefaultLimits())
|
||
|
|
first := g.Evaluate(in)
|
||
|
|
for i := 0; i < 100; i++ {
|
||
|
|
again := g.Evaluate(in)
|
||
|
|
if again.Allowed != first.Allowed || len(again.Reasons) != len(first.Reasons) {
|
||
|
|
t.Fatalf("verdict varied between runs")
|
||
|
|
}
|
||
|
|
for j := range first.Reasons {
|
||
|
|
if again.Reasons[j] != first.Reasons[j] {
|
||
|
|
t.Fatalf("reason order varied: %q vs %q", first.Reasons[j], again.Reasons[j])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func mentions(reasons []string, substr string) bool {
|
||
|
|
for _, r := range reasons {
|
||
|
|
if strings.Contains(strings.ToLower(r), strings.ToLower(substr)) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|