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:
2026-07-17 22:54:44 +02:00
parent 463bdacf5c
commit c96c795126
11 changed files with 1282 additions and 35 deletions

View 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)
}
}

View 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)

View 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)
}
}
}

View 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)
}
})
}

View File

@@ -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 {

View 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)
}
})
}
}

View 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)
}

View File

@@ -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)

View 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)
}
})
}
}

View 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)
}
})
}
}

View File

@@ -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 |