Files
felhom-agent/internal/reconcile/classify_test.go
T
admin 8033a522cd feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)
- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
  blesses replacing the root-adjacent binary). classify test + companion
  (TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
  params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
  + WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
  wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
  agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
  commit + loud WARN + marker left for report visibility; shutdown-before-dwell
  leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
  seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
  stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
  the report seam + MaybeCommit goroutine after core init.

Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 15:32:15 +02:00

81 lines
3.1 KiB
Go

package reconcile
import (
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
)
func TestClassify_BenignClasses(t *testing.T) {
for _, c := range []OpClass{ClassStart, ClassStop, ClassSetConfig, ClassCreate, ClassRestart} {
if got := Classify(c, Provenance{}); got != Benign {
t.Errorf("Classify(%s) = %s, want benign", c, got)
}
}
}
func TestClassify_DestructiveClassesNeedSignature(t *testing.T) {
for _, c := range []OpClass{ClassGuestDestroy, ClassStorageWipe, ClassRestoreOverwrite, ClassDecommission, ClassKeyRotation, ClassAgentUpdate} {
if got := Classify(c, Provenance{}); got != Destructive {
t.Errorf("Classify(%s) = %s, want destructive", c, got)
}
}
}
func TestClassify_InternalProvenanceMakesDestroyBenign(t *testing.T) {
// Same-transaction create → compensating rollback is benign (§10).
if got := Classify(ClassGuestDestroy, Provenance{SameTxnCreated: true}); got != Benign {
t.Errorf("same-txn destroy = %s, want benign", got)
}
// Agent-tagged scratch teardown is benign (§8).
if got := Classify(ClassGuestDestroy, Provenance{AgentTaggedScratch: true}); got != Benign {
t.Errorf("scratch destroy = %s, want benign", got)
}
}
func TestClassify_KeyRotationAlwaysDestructive(t *testing.T) {
// Even with internal provenance, key-rotation stays signed (role-scoping decides
// which key) — provenance flags don't apply to it.
if got := Classify(ClassKeyRotation, Provenance{SameTxnCreated: true, AgentTaggedScratch: true}); got != Destructive {
t.Errorf("key_rotation = %s, want destructive", got)
}
}
// TASK D1: agent_update (replacing the root-adjacent binary) is ALWAYS destructive — no
// agent-internal provenance can bless it unsigned (a compromised process must not self-update).
// This is what gates the signed-jobs binary swap; if it flipped to Benign the runner would execute
// an unsigned agent_update (the companion the signedjobs ride-along tests rely on).
func TestClassify_AgentUpdateAlwaysDestructive(t *testing.T) {
if got := Classify(ClassAgentUpdate, Provenance{SameTxnCreated: true, AgentTaggedScratch: true}); got != Destructive {
t.Errorf("agent_update = %s, want destructive even with internal provenance", got)
}
}
func TestClassify_UnknownClassFailsSafe(t *testing.T) {
if got := Classify(OpClass("totally_unknown_op"), Provenance{}); got != Destructive {
t.Errorf("unknown class = %s, want destructive (fail-safe)", got)
}
}
func TestRoleAuthorizes(t *testing.T) {
op := authz.RoleOperational
rec := authz.RoleRecovery
cases := []struct {
role authz.KeyRole
class OpClass
want bool
}{
{op, ClassGuestDestroy, true}, // operational does ordinary destructive
{op, ClassDecommission, true}, //
{op, ClassKeyRotation, true}, // operational does planned rotation
{rec, ClassGuestDestroy, false}, // recovery may NOT do ordinary destructive
{rec, ClassStorageWipe, false}, //
{rec, ClassKeyRotation, true}, // recovery authorizes ONLY rotation
}
for _, c := range cases {
if got := roleAuthorizes(c.role, c.class); got != c.want {
t.Errorf("roleAuthorizes(%s, %s) = %v, want %v", c.role, c.class, got, c.want)
}
}
}