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