test: add unit tests for 6 previously-untested packages (R7)
Added pure unit tests for all packages that had 0% coverage. Where pure logic was entangled with DB calls, extracted testable helpers first. internal/domain (0% -> 100%): - TestIsNil, TestCanTransition (all 30 state transitions), TestSentinelErrors, TestSignalTransitionsComplete internal/learning (0% -> 26.2%): - Refactored processGroup to extract 4 pure helpers: countOutcomes, computeConfidence, shouldValidate, shouldQuarantine - TestWilsonLowerBound (monotonicity, edge cases, sample-size cap) - TestCountOutcomes, TestComputeConfidence, TestShouldValidate, TestShouldQuarantine (table-driven) - Remaining gap: extractPatterns/processGroup DB calls need make test-db internal/policy (39% -> 50%): - Extracted determineRoute from ClassifySignal (pure route logic) - TestDetermineRoute (7 cases covering global/entity kill-switches, approval) - Remaining gap: ClassifySignal/computeBlastRadius need DB mock internal/knowledge (0% -> 14.2%): - TestContentHash, TestStr, TestStrSlice, TestMapVal, TestToPGArray - Documented latent bug: toPGArray doesn't escape " or \\ in tags - Remaining gap: ingest* functions need make test-db internal/actuator (0% -> 14.7%): - TestSSHErrorClassString, TestClassifySSHError (11 cases incl. net.Error mock) - TestParseProcedure, TestSetDefaultSSHTimeout - Circuit breaker full state-machine test (open/close/reset/per-target) - Remaining gap: ExecuteProcedure/ProvisionLXC need SSH+DB fixtures internal/scheduler (0% -> 7.3%): - TestParsePingLatency (Linux/macOS formats), TestAllowlistedScript - TestEvaluateSeverity (threshold logic, crit:0 skip, signalKind fallback) - Remaining gap: checkHTTP/checkTCP need httptest; runCheckPass needs DB internal/notifier (0% -> 6.4%): - TestHashToken, TestGenerateApprovalToken (HMAC re-derivation) - Remaining gap: checkReaction/sendMatrixAlert need httptest; DB funcs need make test-db All tests pass with -race. domain hits its 60% gate at 100%. The remaining packages need integration tests (make test-db) and/or httptest-based tests to reach their coverage gates — tracked as follow-up.
This commit is contained in:
109
internal/actuator/circuit_breaker_test.go
Normal file
109
internal/actuator/circuit_breaker_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewCircuitBreakerDefaults(t *testing.T) {
|
||||
cb := newCircuitBreaker(0, 0)
|
||||
if cb.threshold != 3 {
|
||||
t.Errorf("threshold = %d, want 3", cb.threshold)
|
||||
}
|
||||
if cb.cooldownS != 300 {
|
||||
t.Errorf("cooldownS = %d, want 300", cb.cooldownS)
|
||||
}
|
||||
|
||||
cb = newCircuitBreaker(5, 60)
|
||||
if cb.threshold != 5 {
|
||||
t.Errorf("threshold = %d, want 5", cb.threshold)
|
||||
}
|
||||
if cb.cooldownS != 60 {
|
||||
t.Errorf("cooldownS = %d, want 60", cb.cooldownS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreakerIsOpenFresh(t *testing.T) {
|
||||
cb := newCircuitBreaker(3, 60)
|
||||
if cb.isOpen("host:A") {
|
||||
t.Errorf("fresh circuit should be closed, got open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreakerOpensAtThreshold(t *testing.T) {
|
||||
cb := newCircuitBreaker(3, 60)
|
||||
// threshold-1 failures → still closed
|
||||
cb.recordFailure("host:A")
|
||||
cb.recordFailure("host:A")
|
||||
if cb.isOpen("host:A") {
|
||||
t.Fatalf("circuit should be closed after threshold-1 failures")
|
||||
}
|
||||
// one more → open
|
||||
cb.recordFailure("host:A")
|
||||
if !cb.isOpen("host:A") {
|
||||
t.Fatalf("circuit should be open after threshold failures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreakerClosesAfterCooldown(t *testing.T) {
|
||||
cb := newCircuitBreaker(1, 60)
|
||||
// Force open
|
||||
cb.recordFailure("host:A")
|
||||
if !cb.isOpen("host:A") {
|
||||
t.Fatalf("circuit should be open")
|
||||
}
|
||||
// Manipulate the cooldown timestamp to the past to simulate expiry.
|
||||
cb.mu.Lock()
|
||||
cb.cooldowns["host:A"] = time.Now().Add(-1 * time.Second)
|
||||
cb.mu.Unlock()
|
||||
|
||||
if cb.isOpen("host:A") {
|
||||
t.Fatalf("circuit should be closed after cooldown expired")
|
||||
}
|
||||
// Failure count should have been reset by isOpen.
|
||||
cb.mu.Lock()
|
||||
got := cb.failures["host:A"]
|
||||
cb.mu.Unlock()
|
||||
if got != 0 {
|
||||
t.Errorf("failure count after cooldown reset = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreakerRecordSuccessResets(t *testing.T) {
|
||||
cb := newCircuitBreaker(3, 60)
|
||||
cb.recordFailure("host:A")
|
||||
cb.recordFailure("host:A")
|
||||
|
||||
cb.recordSuccess("host:A")
|
||||
|
||||
cb.mu.Lock()
|
||||
got := cb.failures["host:A"]
|
||||
cb.mu.Unlock()
|
||||
if got != 0 {
|
||||
t.Errorf("failure count after success = %d, want 0", got)
|
||||
}
|
||||
if cb.isOpen("host:A") {
|
||||
t.Errorf("circuit should be closed after success reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreakerPerTargetIsolation(t *testing.T) {
|
||||
cb := newCircuitBreaker(2, 60)
|
||||
cb.recordFailure("host:A")
|
||||
cb.recordFailure("host:A") // host:A now at threshold → open
|
||||
|
||||
if !cb.isOpen("host:A") {
|
||||
t.Fatalf("host:A should be open")
|
||||
}
|
||||
if cb.isOpen("host:B") {
|
||||
t.Errorf("host:B should be closed (isolated from host:A)")
|
||||
}
|
||||
|
||||
// host:B has no failures recorded
|
||||
cb.mu.Lock()
|
||||
gotB := cb.failures["host:B"]
|
||||
cb.mu.Unlock()
|
||||
if gotB != 0 {
|
||||
t.Errorf("host:B failure count = %d, want 0", gotB)
|
||||
}
|
||||
}
|
||||
151
internal/actuator/ssh_test.go
Normal file
151
internal/actuator/ssh_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestSSHErrorClassString(t *testing.T) {
|
||||
cases := []struct {
|
||||
class SSHErrorClass
|
||||
want string
|
||||
}{
|
||||
{SSHErrorNetwork, "network"},
|
||||
{SSHErrorAuth, "auth"},
|
||||
{SSHErrorTimeout, "timed_out"},
|
||||
{SSHErrorRemote, "remote"},
|
||||
{SSHErrorOther, "other"},
|
||||
{SSHErrorClass(999), "unknown"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.class.String(); got != c.want {
|
||||
t.Errorf("SSHErrorClass(%d).String() = %q, want %q", c.class, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutNetErr is a custom net.Error implementation for testing.
|
||||
type timeoutNetErr struct {
|
||||
timeout bool
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *timeoutNetErr) Error() string { return e.msg }
|
||||
func (e *timeoutNetErr) Timeout() bool { return e.timeout }
|
||||
func (e *timeoutNetErr) Temporary() bool { return false }
|
||||
|
||||
func TestClassifySSHError(t *testing.T) {
|
||||
// ssh.ExitError fields are unexported, but classifySSHError only checks
|
||||
// for the type via errors.As, so the zero value is sufficient.
|
||||
exitErr := &ssh.ExitError{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want SSHErrorClass
|
||||
}{
|
||||
{"nil", nil, SSHErrorOther},
|
||||
{"deadline exceeded", context.DeadlineExceeded, SSHErrorTimeout},
|
||||
{"net error timeout true", &timeoutNetErr{timeout: true, msg: "i/o timeout"}, SSHErrorNetwork},
|
||||
{"net error timeout false", &timeoutNetErr{timeout: false, msg: "connection refused"}, SSHErrorNetwork},
|
||||
{"unable to authenticate", errors.New("unable to authenticate, no supported methods remain"), SSHErrorAuth},
|
||||
{"no supported methods remain", errors.New("no supported methods remain (server sent publickey)"), SSHErrorAuth},
|
||||
{"ssh handshake failed", errors.New("ssh: handshake failed: read tcp -> eof"), SSHErrorAuth},
|
||||
{"publickey", errors.New("publickey denied"), SSHErrorAuth},
|
||||
{"permission denied", errors.New("permission denied (publickey)"), SSHErrorAuth},
|
||||
{"exit error", exitErr, SSHErrorRemote},
|
||||
{"generic error", errors.New("something went wrong"), SSHErrorOther},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := classifySSHError(c.err); got != c.want {
|
||||
t.Errorf("classifySSHError(%v) = %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcedure(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantErr bool
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "valid with steps",
|
||||
data: []byte(`{"steps":[{"runner":"shell","command":"echo hi"}]}`),
|
||||
wantErr: false,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
data: []byte(`{not json`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty bytes",
|
||||
data: []byte{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid no steps key",
|
||||
data: []byte(`{"foo":"bar"}`),
|
||||
wantErr: false,
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "valid with extra fields",
|
||||
data: []byte(`{"extra":"ignored","steps":[{"runner":"verify","command":"true"}],"more":123}`),
|
||||
wantErr: false,
|
||||
wantLen: 1,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
proc, err := ParseProcedure(c.data)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil (proc=%+v)", proc)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(proc.Steps) != c.wantLen {
|
||||
t.Errorf("got %d steps, want %d", len(proc.Steps), c.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultSSHTimeout(t *testing.T) {
|
||||
mu.Lock()
|
||||
orig := defaultSSHTimeout
|
||||
mu.Unlock()
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
defaultSSHTimeout = orig
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
newTimeout := 42 * time.Second
|
||||
SetDefaultSSHTimeout(newTimeout)
|
||||
|
||||
mu.Lock()
|
||||
got := defaultSSHTimeout
|
||||
mu.Unlock()
|
||||
|
||||
if got != newTimeout {
|
||||
t.Errorf("defaultSSHTimeout = %v, want %v", got, newTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure timeoutNetErr satisfies net.Error at compile time.
|
||||
var _ net.Error = (*timeoutNetErr)(nil)
|
||||
139
internal/domain/domain_test.go
Normal file
139
internal/domain/domain_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsNil(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
u UUID
|
||||
want bool
|
||||
}{
|
||||
{"empty string", UUID(""), true},
|
||||
{"single char", UUID("x"), false},
|
||||
{"uuid string", UUID("550e8400-e29b-41d4-a716-446655440000"), false},
|
||||
{"nil literal", UUID(""), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.u.IsNil()
|
||||
if got != c.want {
|
||||
t.Errorf("UUID(%q).IsNil() = %v, want %v", c.u, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanTransition(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
want bool
|
||||
}
|
||||
var cases []tc
|
||||
|
||||
for from, targets := range ValidSignalTransitions {
|
||||
for _, to := range targets {
|
||||
cases = append(cases, tc{from + "->" + to, from, to, true})
|
||||
}
|
||||
}
|
||||
|
||||
disallowed := []tc{
|
||||
{"raised->raised", SignalRaised, SignalRaised, false},
|
||||
{"resolved->raised", SignalResolved, SignalRaised, false},
|
||||
{"failed->raised", SignalFailed, SignalRaised, false},
|
||||
{"acknowledged->raised", SignalAcknowledged, SignalRaised, false},
|
||||
{"muted->resolved", SignalMuted, SignalResolved, false},
|
||||
{"acting->acknowledged", SignalActing, SignalAcknowledged, false},
|
||||
}
|
||||
cases = append(cases, disallowed...)
|
||||
|
||||
cases = append(cases,
|
||||
tc{"unknown source", "nonexistent", SignalRaised, false},
|
||||
tc{"unknown target", SignalRaised, "nonexistent", false},
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s := &Signal{State: c.from}
|
||||
got := s.CanTransition(c.to)
|
||||
if got != c.want {
|
||||
t.Errorf("CanTransition(%q -> %q) = %v, want %v", c.from, c.to, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
msg string
|
||||
}{
|
||||
{"ErrNotFound", ErrNotFound, "entity not found"},
|
||||
{"ErrInvalidTransition", ErrInvalidTransition, "invalid lifecycle transition"},
|
||||
{"ErrApprovalRequired", ErrApprovalRequired, "operator approval required"},
|
||||
{"ErrAutonomyBlocked", ErrAutonomyBlocked, "autonomy policy blocks this action"},
|
||||
{"ErrConflict", ErrConflict, "concurrent modification conflict"},
|
||||
{"ErrCircuitOpen", ErrCircuitOpen, "circuit breaker open for target"},
|
||||
{"ErrAbstractType", ErrAbstractType, "cannot instantiate abstract entity type"},
|
||||
{"ErrInvalidEdge", ErrInvalidEdge, "relationship endpoint type mismatch"},
|
||||
{"ErrCardinality", ErrCardinality, "relationship cardinality violation"},
|
||||
{"ErrSeedHashMismatch", ErrSeedHashMismatch, "seed content hash mismatch"},
|
||||
{"ErrAlreadyExists", ErrAlreadyExists, "entity already exists"},
|
||||
{"ErrQuarantined", ErrQuarantined, "pattern is quarantined"},
|
||||
{"ErrSkillDeprecated", ErrSkillDeprecated, "skill is deprecated"},
|
||||
{"ErrInvalidInput", ErrInvalidInput, "invalid input"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if c.err == nil {
|
||||
t.Fatal("sentinel error is nil")
|
||||
}
|
||||
if !errors.Is(c.err, c.err) {
|
||||
t.Errorf("errors.Is failed for %s", c.name)
|
||||
}
|
||||
if c.err.Error() != c.msg {
|
||||
t.Errorf("Error() = %q, want %q", c.err.Error(), c.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalTransitionsComplete(t *testing.T) {
|
||||
// Non-terminal states must be keys in ValidSignalTransitions.
|
||||
// SignalResolved is a terminal state (no outgoing transitions) and is
|
||||
// intentionally absent from the map.
|
||||
nonTerminal := []string{
|
||||
SignalRaised,
|
||||
SignalAcknowledged,
|
||||
SignalActing,
|
||||
SignalMuted,
|
||||
SignalFailed,
|
||||
}
|
||||
for _, state := range nonTerminal {
|
||||
targets, ok := ValidSignalTransitions[state]
|
||||
if !ok {
|
||||
t.Errorf("non-terminal state %q missing from ValidSignalTransitions", state)
|
||||
continue
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
t.Errorf("state %q maps to empty transition list", state)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved is terminal: it should not appear as a source key.
|
||||
if _, ok := ValidSignalTransitions[SignalResolved]; ok {
|
||||
t.Errorf("terminal state %q should not have outgoing transitions", SignalResolved)
|
||||
}
|
||||
|
||||
// No state anywhere in the map may map to nil/empty.
|
||||
for state, targets := range ValidSignalTransitions {
|
||||
if len(targets) == 0 {
|
||||
t.Errorf("state %q maps to empty/nil transition list", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
155
internal/knowledge/seed_test.go
Normal file
155
internal/knowledge/seed_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContentHash(t *testing.T) {
|
||||
t.Run("determinism", func(t *testing.T) {
|
||||
a := contentHash("hello")
|
||||
b := contentHash("hello")
|
||||
if a != b {
|
||||
t.Errorf("contentHash not deterministic: %q != %q", a, b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string known sha256", func(t *testing.T) {
|
||||
got := contentHash("")
|
||||
h := sha256.Sum256([]byte(""))
|
||||
want := hex.EncodeToString(h[:])
|
||||
if got != want {
|
||||
t.Errorf("contentHash(\"\") = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different inputs different outputs", func(t *testing.T) {
|
||||
if contentHash("a") == contentHash("b") {
|
||||
t.Error("different inputs produced same hash")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("output is 64-char hex", func(t *testing.T) {
|
||||
got := contentHash("anything")
|
||||
if len(got) != 64 {
|
||||
t.Errorf("len = %d, want 64", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
|
||||
if !isHex {
|
||||
t.Errorf("non-hex char %q in hash %q", r, got)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m map[string]any
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"missing key", map[string]any{}, "nope", ""},
|
||||
{"string value", map[string]any{"k": "v"}, "k", "v"},
|
||||
{"int value", map[string]any{"k": 42}, "k", ""},
|
||||
{"nil value", map[string]any{"k": nil}, "k", ""},
|
||||
{"empty string", map[string]any{"k": ""}, "k", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := str(c.m, c.key)
|
||||
if got != c.want {
|
||||
t.Errorf("str() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrSlice(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m map[string]any
|
||||
key string
|
||||
want []string
|
||||
}{
|
||||
{"missing key", map[string]any{}, "tags", nil},
|
||||
{"all strings", map[string]any{"tags": []any{"a", "b", "c"}}, "tags", []string{"a", "b", "c"}},
|
||||
{"mixed types", map[string]any{"tags": []any{1, "a", true, "b"}}, "tags", []string{"a", "b"}},
|
||||
{"empty array", map[string]any{"tags": []any{}}, "tags", []string{}},
|
||||
{"nil elements filtered", map[string]any{"tags": []any{nil, "a", nil, "b"}}, "tags", []string{"a", "b"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := strSlice(c.m, c.key)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("len = %d, want %d (got %v)", len(got), len(c.want), got)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapVal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m map[string]any
|
||||
key string
|
||||
want map[string]any
|
||||
}{
|
||||
{"missing key", map[string]any{}, "nope", nil},
|
||||
{"present map", map[string]any{"k": map[string]any{"x": 1}}, "k", map[string]any{"x": 1}},
|
||||
{"wrong type string", map[string]any{"k": "v"}, "k", nil},
|
||||
{"nested map", map[string]any{"k": map[string]any{"a": map[string]any{"b": 2}}}, "k", map[string]any{"a": map[string]any{"b": 2}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := mapVal(c.m, c.key)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("mapVal() = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPGArray(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tags []string
|
||||
want string
|
||||
}{
|
||||
{"empty", []string{}, "{}"},
|
||||
{"single", []string{"a"}, `{"a"}`},
|
||||
{"multiple", []string{"a", "b"}, `{"a","b"}`},
|
||||
{"nil", nil, "{}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := toPGArray(c.tags)
|
||||
if got != c.want {
|
||||
t.Errorf("toPGArray() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Special chars: tags containing " or \ are NOT escaped by toPGArray.
|
||||
// This is a latent bug — Postgres array literals require these to be
|
||||
// backslash-escaped. Test documents current behavior so a fix is
|
||||
// detectable. Should be fixed.
|
||||
t.Run("special chars unescaped (current buggy behavior)", func(t *testing.T) {
|
||||
got := toPGArray([]string{`a"b`, `c\d`})
|
||||
// Current output: {"a"b","c\d"} — invalid Postgres array literal.
|
||||
want := `{"a"b","c\d"}`
|
||||
if got != want {
|
||||
t.Errorf("toPGArray(special) = %q, want %q (if this changed, the escaping bug was fixed — update this test)", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -83,27 +83,13 @@ func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) ti
|
||||
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
|
||||
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
for _, f := range items {
|
||||
switch f.Outcome {
|
||||
case "success":
|
||||
successCount++
|
||||
case "failure", "unexpected":
|
||||
failureCount++
|
||||
case "partial":
|
||||
successCount++ // partial counts as half-success
|
||||
}
|
||||
}
|
||||
successCount, failureCount := countOutcomes(items)
|
||||
total := successCount + failureCount
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Compute Wilson score lower bound
|
||||
confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95)
|
||||
// Cap by sample size: nothing looks confident before 5 samples
|
||||
confidence = math.Min(confidence, float64(total)/5.0)
|
||||
confidence := computeConfidence(successCount, failureCount)
|
||||
|
||||
// Get or create pattern — first look up existing entity, then upsert.
|
||||
existing, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
|
||||
@@ -148,7 +134,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||
return
|
||||
}
|
||||
|
||||
if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined {
|
||||
if shouldValidate(int(pat.EvidenceCount), float64(pat.Confidence), pat.Quarantined) {
|
||||
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
|
||||
EntityID: pat.EntityID,
|
||||
Status: "validated",
|
||||
@@ -158,8 +144,7 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||
"confidence", confidence, "samples", total)
|
||||
}
|
||||
|
||||
// Anomaly check: >10 identical outcomes within 1h
|
||||
if total > 10 {
|
||||
if shouldQuarantine(total) {
|
||||
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
|
||||
EntityID: pat.EntityID,
|
||||
Quarantined: true,
|
||||
@@ -169,6 +154,45 @@ func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
|
||||
}
|
||||
}
|
||||
|
||||
// countOutcomes tallies feedback items into success and failure counts.
|
||||
// "partial" counts as a half-success (increments success).
|
||||
func countOutcomes(items []sqlcgen.GetFeedbackAfterWatermarkRow) (success, failure int) {
|
||||
for _, f := range items {
|
||||
switch f.Outcome {
|
||||
case "success":
|
||||
success++
|
||||
case "failure", "unexpected":
|
||||
failure++
|
||||
case "partial":
|
||||
success++
|
||||
}
|
||||
}
|
||||
return success, failure
|
||||
}
|
||||
|
||||
// computeConfidence calculates the Wilson score lower bound, capped by
|
||||
// sample size (nothing looks confident before 5 samples).
|
||||
func computeConfidence(success, failure int) float64 {
|
||||
total := success + failure
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
confidence := wilsonLowerBound(float64(success), float64(total), 0.95)
|
||||
return math.Min(confidence, float64(total)/5.0)
|
||||
}
|
||||
|
||||
// shouldValidate returns true when a pattern has enough evidence and
|
||||
// confidence to be promoted from "hypothesized" to "validated".
|
||||
func shouldValidate(evidenceCount int, confidence float64, quarantined bool) bool {
|
||||
return evidenceCount >= 5 && confidence >= 0.7 && !quarantined
|
||||
}
|
||||
|
||||
// shouldQuarantine returns true when an anomaly burst is detected
|
||||
// (>10 identical outcomes, indicating a runaway loop rather than organic feedback).
|
||||
func shouldQuarantine(total int) bool {
|
||||
return total > 10
|
||||
}
|
||||
|
||||
// wilsonLowerBound computes the Wilson score interval lower bound.
|
||||
// Conservative estimate of success rate for small sample sizes.
|
||||
func wilsonLowerBound(success, total, z float64) float64 {
|
||||
|
||||
211
internal/learning/learning_test.go
Normal file
211
internal/learning/learning_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package learning
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
)
|
||||
|
||||
func TestWilsonLowerBound(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
success float64
|
||||
total float64
|
||||
z float64
|
||||
want float64
|
||||
// For approximate checks
|
||||
approx bool
|
||||
epsilon float64
|
||||
}{
|
||||
{"zero total", 5, 0, 1.96, 0, false, 0},
|
||||
{"zero success", 0, 10, 1.96, 0, true, 1e-10},
|
||||
{"all success small n", 3, 3, 1.96, 0, true, 0.5},
|
||||
{"all success large n", 100, 100, 1.96, 0, true, 0.05},
|
||||
{"half success large n", 50, 100, 1.96, 0.39, true, 0.02},
|
||||
{"higher z gives lower bound", 8, 10, 3.0, 0, true, 0.5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := wilsonLowerBound(tt.success, tt.total, tt.z)
|
||||
if tt.approx {
|
||||
if tt.want > 0 && math.Abs(got-tt.want) > tt.epsilon {
|
||||
t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want ~%v (±%v)", tt.success, tt.total, tt.z, got, tt.want, tt.epsilon)
|
||||
}
|
||||
if got < 0 {
|
||||
t.Errorf("wilsonLowerBound returned negative: %v", got)
|
||||
}
|
||||
if got > 1 {
|
||||
t.Errorf("wilsonLowerBound returned >1: %v", got)
|
||||
}
|
||||
} else {
|
||||
if got != tt.want {
|
||||
t.Errorf("wilsonLowerBound(%v, %v, %v) = %v, want %v", tt.success, tt.total, tt.z, got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWilsonLowerBoundMonotonic(t *testing.T) {
|
||||
prev := 0.0
|
||||
for s := 0.0; s <= 20; s++ {
|
||||
got := wilsonLowerBound(s, 20, 1.96)
|
||||
if got < prev-1e-9 {
|
||||
t.Errorf("not monotonically increasing: s=%v got=%v prev=%v", s, got, prev)
|
||||
}
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []sqlcgen.GetFeedbackAfterWatermarkRow
|
||||
wantSuccess int
|
||||
wantFailure int
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
nil,
|
||||
0, 0,
|
||||
},
|
||||
{
|
||||
"all success",
|
||||
[]sqlcgen.GetFeedbackAfterWatermarkRow{
|
||||
{Outcome: "success"},
|
||||
{Outcome: "success"},
|
||||
},
|
||||
2, 0,
|
||||
},
|
||||
{
|
||||
"all failure",
|
||||
[]sqlcgen.GetFeedbackAfterWatermarkRow{
|
||||
{Outcome: "failure"},
|
||||
{Outcome: "unexpected"},
|
||||
},
|
||||
0, 2,
|
||||
},
|
||||
{
|
||||
"mixed including partial",
|
||||
[]sqlcgen.GetFeedbackAfterWatermarkRow{
|
||||
{Outcome: "success"},
|
||||
{Outcome: "failure"},
|
||||
{Outcome: "partial"},
|
||||
{Outcome: "unexpected"},
|
||||
},
|
||||
2, 2,
|
||||
},
|
||||
{
|
||||
"unknown outcome ignored",
|
||||
[]sqlcgen.GetFeedbackAfterWatermarkRow{
|
||||
{Outcome: "success"},
|
||||
{Outcome: "bogus"},
|
||||
},
|
||||
1, 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, f := countOutcomes(tt.items)
|
||||
if s != tt.wantSuccess || f != tt.wantFailure {
|
||||
t.Errorf("countOutcomes() = (%d, %d), want (%d, %d)", s, f, tt.wantSuccess, tt.wantFailure)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeConfidence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
success int
|
||||
failure int
|
||||
wantMax float64
|
||||
wantMin float64
|
||||
}{
|
||||
{"zero total", 0, 0, 0, 0},
|
||||
{"one success no failures", 1, 0, 0.2, 0},
|
||||
{"five success no failures", 5, 0, 1.0, 0.3},
|
||||
{"ten success no failures", 10, 0, 1.0, 0.5},
|
||||
{"half success", 5, 5, 0.5, 0.2},
|
||||
{"all failures", 0, 10, 0.01, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := computeConfidence(tt.success, tt.failure)
|
||||
if got < 0 || got > 1 {
|
||||
t.Errorf("confidence out of [0,1]: %v", got)
|
||||
}
|
||||
if got > tt.wantMax+0.01 {
|
||||
t.Errorf("confidence too high: got %v, max ~%v", got, tt.wantMax)
|
||||
}
|
||||
if tt.wantMin > 0 && got < tt.wantMin-0.1 {
|
||||
t.Errorf("confidence too low: got %v, min ~%v", got, tt.wantMin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeConfidenceSampleSizeCap(t *testing.T) {
|
||||
got := computeConfidence(1, 0)
|
||||
if got > 0.2+1e-9 {
|
||||
t.Errorf("sample size cap not applied: 1 sample should cap at 1/5=0.2, got %v", got)
|
||||
}
|
||||
got = computeConfidence(4, 0)
|
||||
if got > 0.8+1e-9 {
|
||||
t.Errorf("sample size cap not applied: 4 samples should cap at 4/5=0.8, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
evidenceCount int
|
||||
confidence float64
|
||||
quarantined bool
|
||||
want bool
|
||||
}{
|
||||
{"enough evidence and confidence", 5, 0.7, false, true},
|
||||
{"high evidence high confidence", 10, 0.9, false, true},
|
||||
{"not enough evidence", 4, 0.9, false, false},
|
||||
{"not enough confidence", 5, 0.69, false, false},
|
||||
{"quarantined blocks validation", 10, 0.9, true, false},
|
||||
{"exactly at threshold", 5, 0.7, false, true},
|
||||
{"zero evidence", 0, 0.9, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldValidate(tt.evidenceCount, tt.confidence, tt.quarantined)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldValidate(%d, %v, %v) = %v, want %v", tt.evidenceCount, tt.confidence, tt.quarantined, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldQuarantine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
total int
|
||||
want bool
|
||||
}{
|
||||
{"zero", 0, false},
|
||||
{"small", 5, false},
|
||||
{"at threshold", 10, false},
|
||||
{"over threshold", 11, true},
|
||||
{"large burst", 50, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldQuarantine(tt.total)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldQuarantine(%d) = %v, want %v", tt.total, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
187
internal/notifier/notifier_test.go
Normal file
187
internal/notifier/notifier_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var hex64Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
func TestHashToken(t *testing.T) {
|
||||
// sha256("") == e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
emptyHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
t.Run("determinism same input same output", func(t *testing.T) {
|
||||
a := hashToken("approval-token-123")
|
||||
b := hashToken("approval-token-123")
|
||||
if a != b {
|
||||
t.Fatalf("hashToken not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string known sha256", func(t *testing.T) {
|
||||
got := hashToken("")
|
||||
if got != emptyHash {
|
||||
t.Fatalf("hashToken(\"\") = %q, want %q", got, emptyHash)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different inputs different outputs", func(t *testing.T) {
|
||||
a := hashToken("one")
|
||||
b := hashToken("two")
|
||||
if a == b {
|
||||
t.Fatalf("hashToken collided for different inputs: %q", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("output is valid 64-char hex", func(t *testing.T) {
|
||||
for _, in := range []string{"", "abc", "some-longer-token-value-xyz"} {
|
||||
got := hashToken(in)
|
||||
if !hex64Re.MatchString(got) {
|
||||
t.Fatalf("hashToken(%q) = %q, not 64-char lowercase hex", in, got)
|
||||
}
|
||||
// also must decode cleanly to 32 bytes
|
||||
b, err := hex.DecodeString(got)
|
||||
if err != nil {
|
||||
t.Fatalf("hashToken(%q) decode error: %v", in, err)
|
||||
}
|
||||
if len(b) != 32 {
|
||||
t.Fatalf("hashToken(%q) decoded len = %d, want 32", in, len(b))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateApprovalToken(t *testing.T) {
|
||||
id := uuid.New()
|
||||
|
||||
t.Run("output is 64-char hex", func(t *testing.T) {
|
||||
tok := generateApprovalToken(id, "super-secret")
|
||||
if !hex64Re.MatchString(tok) {
|
||||
t.Fatalf("generateApprovalToken = %q, not 64-char lowercase hex", tok)
|
||||
}
|
||||
b, err := hex.DecodeString(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(b) != 32 {
|
||||
t.Fatalf("decoded len = %d, want 32 (sha256)", len(b))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty secret falls back to dev secret no panic", func(t *testing.T) {
|
||||
tok := generateApprovalToken(id, "")
|
||||
if tok == "" {
|
||||
t.Fatal("empty secret produced empty token")
|
||||
}
|
||||
if !hex64Re.MatchString(tok) {
|
||||
t.Fatalf("empty-secret token %q not 64-char hex", tok)
|
||||
}
|
||||
})
|
||||
|
||||
// Non-determinism: time.Now().UnixNano() is embedded in the HMAC message,
|
||||
// so two calls with identical inputs produce different tokens (unless the
|
||||
// clock has nanosecond-identical reads, which we do not assert against).
|
||||
t.Run("same inputs twice produce different tokens (time-based)", func(t *testing.T) {
|
||||
a := generateApprovalToken(id, "stable-secret")
|
||||
b := generateApprovalToken(id, "stable-secret")
|
||||
if a == b {
|
||||
// Not a hard failure (clock granularity), but document expectation.
|
||||
t.Logf("note: two immediate calls returned identical token %q — clock resolution collapsed", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different secrets produce different tokens", func(t *testing.T) {
|
||||
a := generateApprovalToken(id, "secret-a")
|
||||
b := generateApprovalToken(id, "secret-b")
|
||||
if a == b {
|
||||
t.Fatalf("different secrets produced same token %q", a)
|
||||
}
|
||||
})
|
||||
|
||||
// HMAC correctness: re-derive the token with the same secret + approvalID
|
||||
// using a freshly captured timestamp window is impossible because we don't
|
||||
// observe the embedded timestamp. Instead, verify the token is a valid
|
||||
// HMAC-SHA256 by brute-forcing a small time window around now: reconstruct
|
||||
// mac(secret, approvalID || ts) for ts in [now-N, now] and confirm one
|
||||
// matches. This proves the token genuinely is an HMAC over (approvalID, ts)
|
||||
// with the supplied secret.
|
||||
t.Run("token is HMAC-SHA256 over approvalID+timestamp with secret", func(t *testing.T) {
|
||||
secret := "hmac-verify-secret"
|
||||
before := nowNanos()
|
||||
tok := generateApprovalToken(id, secret)
|
||||
after := nowNanos()
|
||||
|
||||
// The token's embedded ts is captured inside generateApprovalToken,
|
||||
// which is called after `before` was sampled — so ts ∈ [before, after].
|
||||
// Add a tiny ±band to absorb scheduler jitter on loaded runners.
|
||||
lo := before - 10_000
|
||||
hi := after + 10_000
|
||||
matched := false
|
||||
for ts := lo; ts <= hi; ts++ {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(id.String()))
|
||||
mac.Write([]byte(formatInt(ts)))
|
||||
cand := hex.EncodeToString(mac.Sum(nil))
|
||||
if hmac.Equal([]byte(cand), []byte(tok)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("token %q did not match any HMAC in window [%d,%d]; not a valid HMAC-SHA256 over approvalID+ts", tok, lo, hi)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty secret HMAC uses dev fallback secret", func(t *testing.T) {
|
||||
before := nowNanos()
|
||||
tok := generateApprovalToken(id, "")
|
||||
after := nowNanos()
|
||||
dev := "dev-secret-do-not-use-in-prod"
|
||||
lo := before - 10_000
|
||||
hi := after + 10_000
|
||||
matched := false
|
||||
for ts := lo; ts <= hi; ts++ {
|
||||
mac := hmac.New(sha256.New, []byte(dev))
|
||||
mac.Write([]byte(id.String()))
|
||||
mac.Write([]byte(formatInt(ts)))
|
||||
cand := hex.EncodeToString(mac.Sum(nil))
|
||||
if hmac.Equal([]byte(cand), []byte(tok)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("empty-secret token %q did not match dev-fallback HMAC", tok)
|
||||
}
|
||||
})
|
||||
|
||||
// sanity: token should not leak the secret in plaintext
|
||||
t.Run("token does not contain secret substring", func(t *testing.T) {
|
||||
secret := "leakcheck-secret-xyz"
|
||||
tok := generateApprovalToken(id, secret)
|
||||
if strings.Contains(tok, secret) {
|
||||
t.Fatalf("token %q contains secret substring %q", tok, secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// nowNanos returns the current nanosecond count, matching the time source
|
||||
// used by generateApprovalToken (time.Now().UnixNano()).
|
||||
func nowNanos() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
// formatInt mirrors fmt.Sprintf("%d", ...) used by the production code so the
|
||||
// re-derivation in tests is byte-identical.
|
||||
func formatInt(n int64) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
@@ -92,21 +92,7 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
||||
}
|
||||
|
||||
// Determine route
|
||||
route := "escalate"
|
||||
autonomyCheck := ""
|
||||
|
||||
if globalAutoAct == "off" || globalAutoAct == "false" {
|
||||
route = "escalate"
|
||||
autonomyCheck = "blocked: global auto_act disabled"
|
||||
} else if entityAutoAct == "true" {
|
||||
route = "escalate"
|
||||
autonomyCheck = "blocked: per-entity kill-switch"
|
||||
} else if approvalRequired == "none" {
|
||||
route = "auto-act"
|
||||
autonomyCheck = "allowed"
|
||||
} else {
|
||||
autonomyCheck = "requires approval: " + approvalRequired
|
||||
}
|
||||
route, autonomyCheck := determineRoute(globalAutoAct, entityAutoAct, approvalRequired)
|
||||
|
||||
// Compute blast radius
|
||||
blastRadius := computeBlastRadius(ctx, c.DB, targetEntityID)
|
||||
@@ -136,6 +122,21 @@ func (c *Classifier) ClassifySignal(ctx context.Context, signalEntityID, targetE
|
||||
}, nil
|
||||
}
|
||||
|
||||
// determineRoute evaluates autonomy settings and approval requirements to
|
||||
// decide whether a signal should auto-act, escalate, or hold for approval.
|
||||
func determineRoute(globalAutoAct, entityAutoAct, approvalRequired string) (route, autonomyCheck string) {
|
||||
if globalAutoAct == "off" || globalAutoAct == "false" {
|
||||
return "escalate", "blocked: global auto_act disabled"
|
||||
}
|
||||
if entityAutoAct == "true" {
|
||||
return "escalate", "blocked: per-entity kill-switch"
|
||||
}
|
||||
if approvalRequired == "none" {
|
||||
return "auto-act", "allowed"
|
||||
}
|
||||
return "escalate", "requires approval: " + approvalRequired
|
||||
}
|
||||
|
||||
// computeBlastRadius traverses relationships to find affected entities.
|
||||
func computeBlastRadius(ctx context.Context, dbc interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
|
||||
62
internal/policy/classify_test.go
Normal file
62
internal/policy/classify_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package policy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetermineRoute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
globalAutoAct string
|
||||
entityAutoAct string
|
||||
approvalRequired string
|
||||
wantRoute string
|
||||
wantCheck string
|
||||
}{
|
||||
{
|
||||
"global auto_act off blocks everything",
|
||||
"off", "", "none",
|
||||
"escalate", "blocked: global auto_act disabled",
|
||||
},
|
||||
{
|
||||
"global auto_act false blocks everything",
|
||||
"false", "", "none",
|
||||
"escalate", "blocked: global auto_act disabled",
|
||||
},
|
||||
{
|
||||
"per-entity kill-switch blocks",
|
||||
"on", "true", "none",
|
||||
"escalate", "blocked: per-entity kill-switch",
|
||||
},
|
||||
{
|
||||
"approval none auto-acts",
|
||||
"on", "", "none",
|
||||
"auto-act", "allowed",
|
||||
},
|
||||
{
|
||||
"approval required escalates",
|
||||
"on", "", "operator",
|
||||
"escalate", "requires approval: operator",
|
||||
},
|
||||
{
|
||||
"approval none with empty global defaults to auto-act",
|
||||
"", "", "none",
|
||||
"auto-act", "allowed",
|
||||
},
|
||||
{
|
||||
"global on, no entity kill, approval confirmation",
|
||||
"on", "", "confirmation",
|
||||
"escalate", "requires approval: confirmation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
route, check := determineRoute(tt.globalAutoAct, tt.entityAutoAct, tt.approvalRequired)
|
||||
if route != tt.wantRoute {
|
||||
t.Errorf("route = %q, want %q", route, tt.wantRoute)
|
||||
}
|
||||
if check != tt.wantCheck {
|
||||
t.Errorf("autonomyCheck = %q, want %q", check, tt.wantCheck)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
208
internal/scheduler/scheduler_test.go
Normal file
208
internal/scheduler/scheduler_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePingLatency(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
out []byte
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "linux rtt format",
|
||||
out: []byte("PING host (1.2.3.4): 56 data bytes\n--- host ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss\nrtt min/avg/max/mdev = 0.1/2.5/5.0/1.2 ms\n"),
|
||||
want: 2.5,
|
||||
},
|
||||
{
|
||||
name: "macos round-trip format",
|
||||
out: []byte("PING host (1.2.3.4): 56 data bytes\n--- host ping statistics ---\n5 packets transmitted, 5 received, 0% packet loss\nround-trip min/avg/max/stddev = 0.1/2.5/5.0/1.2 ms\n"),
|
||||
want: 2.5,
|
||||
},
|
||||
{
|
||||
name: "no match empty string",
|
||||
out: []byte(""),
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "malformed number in avg slot",
|
||||
out: []byte("rtt min/avg/max/mdev = 0.1/abc/5.0/1.2 ms\n"),
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "truly empty input nil",
|
||||
out: nil,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "linux integer avg",
|
||||
out: []byte("rtt min/avg/max/mdev = 1/3/5/0.5 ms\n"),
|
||||
want: 3.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parsePingLatency(tc.out)
|
||||
if got != tc.want {
|
||||
t.Fatalf("parsePingLatency(%q) = %v, want %v", tc.out, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistedScript(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{name: "cpu_check.sh", in: "cpu_check.sh", want: true},
|
||||
{name: "disk_usage_check.sh", in: "disk_usage_check.sh", want: true},
|
||||
{name: "hyphen allowed", in: "foo-bar.sh", want: true},
|
||||
{name: "underscore allowed", in: "foo_bar.sh", want: true},
|
||||
{name: "uppercase rejected", in: "Foo.sh", want: false},
|
||||
{name: "bare .sh rejected (no name)", in: ".sh", want: false},
|
||||
{name: "path traversal rejected", in: "../etc/passwd", want: false},
|
||||
{name: "empty rejected", in: "", want: false},
|
||||
// foo.sh.sh: regex ^[a-z][a-z0-9_-]+\.sh$ — the [a-z0-9_-]+ cannot
|
||||
// cross the first '.', so after matching "foo.sh" the trailing ".sh"
|
||||
// breaks the $ anchor → no match.
|
||||
{name: "doubled .sh.sh rejected", in: "foo.sh.sh", want: false},
|
||||
{name: "wrong extension rejected", in: "foo.txt", want: false},
|
||||
{name: "single char name rejected (needs 2+)", in: "a.sh", want: false},
|
||||
{name: "two char name accepted", in: "ab.sh", want: true},
|
||||
{name: "digit after first char", in: "a1.sh", want: true},
|
||||
{name: "leading digit rejected", in: "1foo.sh", want: false},
|
||||
{name: "dot in middle rejected", in: "foo.bar.sh", want: false},
|
||||
{name: "space rejected", in: "foo bar.sh", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := allowlistedScript(tc.in)
|
||||
if got != tc.want {
|
||||
t.Fatalf("allowlistedScript(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateSeverity(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
signalKind string
|
||||
config []byte
|
||||
metrics map[string]float64
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty config down signal",
|
||||
kind: "ping",
|
||||
signalKind: "down",
|
||||
config: nil,
|
||||
metrics: map[string]float64{},
|
||||
want: "critical",
|
||||
},
|
||||
{
|
||||
name: "empty config high_temp signal",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: nil,
|
||||
metrics: map[string]float64{},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "crit only exceeded",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"crit":90}}`),
|
||||
metrics: map[string]float64{"temp": 95},
|
||||
want: "critical",
|
||||
},
|
||||
{
|
||||
name: "warn only exceeded",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80}}`),
|
||||
metrics: map[string]float64{"temp": 85},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "warn and crit, warn exceeded only",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
|
||||
metrics: map[string]float64{"temp": 85},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "warn and crit, crit exceeded",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
|
||||
metrics: map[string]float64{"temp": 95},
|
||||
want: "critical",
|
||||
},
|
||||
{
|
||||
name: "metric below thresholds falls through to warning",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
|
||||
metrics: map[string]float64{"temp": 70},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "metric below thresholds falls through to down critical",
|
||||
kind: "ping",
|
||||
signalKind: "down",
|
||||
config: []byte(`{"ping_latency_ms":{"warn":100,"crit":200}}`),
|
||||
metrics: map[string]float64{"ping_latency_ms": 50},
|
||||
want: "critical",
|
||||
},
|
||||
{
|
||||
name: "crit zero skipped falls through",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"crit":0}}`),
|
||||
metrics: map[string]float64{"temp": 95},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "metric not in thresholds ignored",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
|
||||
metrics: map[string]float64{"cpu": 99},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "equal to warn triggers warning",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
|
||||
metrics: map[string]float64{"temp": 80},
|
||||
want: "warning",
|
||||
},
|
||||
{
|
||||
name: "equal to crit triggers critical",
|
||||
kind: "ssh-script",
|
||||
signalKind: "high_temp",
|
||||
config: []byte(`{"temp":{"warn":80,"crit":90}}`),
|
||||
metrics: map[string]float64{"temp": 90},
|
||||
want: "critical",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := evaluateSeverity(tc.kind, tc.signalKind, tc.config, tc.metrics)
|
||||
if got != tc.want {
|
||||
t.Fatalf("evaluateSeverity(%q, %q, %s, %v) = %q, want %q",
|
||||
tc.kind, tc.signalKind, tc.config, tc.metrics, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -428,7 +428,7 @@ the gitignore comment); `build` target ensures `bin/` exists; `clean` removes `b
|
||||
| R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium | ✅ done |
|
||||
| R5 | Rewrite `.agents/domains/knowledge/schema.md` + `.agents/shared/llm-wiki.md` for the DB-native model; delete/deprecate root `inventory.yaml` | M | Low | ✅ done |
|
||||
| R6 | Inject desktop `version` from `VERSION` via ldflags; fix `Makefile` `BINARY` colliding with `oikos/` dir | S | Low | ✅ done |
|
||||
| R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low |
|
||||
| R7 | Add tests for `learning` (80% gate), `actuator`, `scheduler`, `domain`, `notifier`, `knowledge` | L | Low | ✅ partial — pure unit tests added for all 6 packages; remaining coverage needs integration tests (`make test-db`) |
|
||||
| R8 | Add `eslint`+`prettier`+`vitest` to `web/`; wire `svelte-check`+`tsc` into CI; add `web/` CI job | M | Low | ✅ done |
|
||||
| R9 | Define `OikosEvent` discriminated union; eliminate ~15 `any` sites in web | S | Low |
|
||||
| R10 | Replace `<svelte:component>` in `ActivityTimeline.svelte:103`; fix `state_referenced_locally` warnings | S | Low |
|
||||
|
||||
Reference in New Issue
Block a user