package reconcile import ( "encoding/json" "errors" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/authz" ) const testHost = "demo-felhom" // captureAudit records gate decisions so tests can assert audit is always written // (audit is a signal, never the guard). type captureAudit struct{ recs []AuditRecord } func (c *captureAudit) Record(r AuditRecord) { c.recs = append(c.recs, r) } // realVerifierAt builds a real authz.Verifier over a durable nonce store at path // (reused across "restart" by reopening the same path), pinning the given signers. func realVerifierAt(t *testing.T, path, hostID string, signers ...authz.AllowedSigner) (*authz.Verifier, *authz.FileNonceStore) { t.Helper() store, err := authz.OpenFileNonceStore(path) if err != nil { t.Fatalf("OpenFileNonceStore: %v", err) } t.Cleanup(func() { store.Close() }) return authz.New(signers, store, hostID), store } // destroyIntent is the canonical destructive fixture: destroy guest 9001, params // {"purge":true} (mirrors the committed slice-2 op_blob.json shape). func destroyIntent(source SourceKind) Intent { return Intent{ Class: ClassGuestDestroy, HostID: testHost, GuestID: "9001", VMID: 9001, ParamsJSON: json.RawMessage(`{"purge":true}`), Source: source, } } func freshWindow() (issued, expires time.Time) { now := time.Now().UTC() return now.Add(-1 * time.Minute), now.Add(10 * time.Minute) } // --- The adversarial matrix: each case must be INDEPENDENTLY rejected (or, the one // positive case, accepted). --- func TestGate_DestructiveJobNoSignatureRefused(t *testing.T) { op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) aud := &captureAudit{} g := NewGate(v, testHost, aud, nil) d := g.Authorize(destroyIntent(SourceOneShotJob), nil) if d.Allowed || d.Reason != ReasonPendingSignature { t.Fatalf("unsigned destructive job: got allowed=%v reason=%s, want pending_signature", d.Allowed, d.Reason) } if len(aud.recs) != 1 || aud.recs[0].Allowed { t.Errorf("decision must be audited as refused: %+v", aud.recs) } } func TestGate_DestructiveDesiredDeltaNoSignatureRefused(t *testing.T) { // Proves the agent distrusts hub DESIRED STATE for destructive change, not just // jobs — same refusal, different source. op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) d := g.Authorize(destroyIntent(SourceDesiredDelta), nil) if d.Allowed || d.Reason != ReasonPendingSignature { t.Fatalf("unsigned destructive delta: got allowed=%v reason=%s, want pending_signature", d.Allowed, d.Reason) } } func TestGate_UnknownSignerRejected(t *testing.T) { pinned := newTestSigner(t) attacker := newTestSigner(t) // NOT pinned v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, pinned.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) issued, expires := freshWindow() signed := attacker.mint("guest_destroy", testHost, "9001", "op1", nonce(), `{"purge":true}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || d.Reason != ReasonRejected || !errors.Is(d.Err, authz.ErrUnknownSigner) { t.Fatalf("forged signer: got allowed=%v reason=%s err=%v, want rejected/ErrUnknownSigner", d.Allowed, d.Reason, d.Err) } } func TestGate_ExpiredSignatureRejected(t *testing.T) { op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) past := time.Now().UTC().Add(-2 * time.Hour) signed := op.mint("guest_destroy", testHost, "9001", "op1", nonce(), `{"purge":true}`, past, past.Add(time.Minute)) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || !errors.Is(d.Err, authz.ErrExpired) { t.Fatalf("expired op: got allowed=%v err=%v, want ErrExpired", d.Allowed, d.Err) } } func TestGate_WrongHostTargetRejected(t *testing.T) { op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) issued, expires := freshWindow() signed := op.mint("guest_destroy", "some-other-host", "9001", "op1", nonce(), `{"purge":true}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || !errors.Is(d.Err, authz.ErrTarget) { t.Fatalf("wrong host: got allowed=%v err=%v, want ErrTarget", d.Allowed, d.Err) } } func TestGate_WrongGuestBindingMismatch(t *testing.T) { // host matches (verifier passes) but the signature names a DIFFERENT guest than the // action — the op-to-action binding rejects it. op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) issued, expires := freshWindow() signed := op.mint("guest_destroy", testHost, "9002", "op1", nonce(), `{"purge":true}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) // intent targets 9001 if d.Allowed || d.Reason != ReasonBindingMismatch { t.Fatalf("guest mismatch: got allowed=%v reason=%s, want binding_mismatch", d.Allowed, d.Reason) } } func TestGate_WrongParamsBindingMismatch(t *testing.T) { op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) issued, expires := freshWindow() // signature authorizes purge=false; the action wants purge=true. signed := op.mint("guest_destroy", testHost, "9001", "op1", nonce(), `{"purge":false}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || d.Reason != ReasonBindingMismatch { t.Fatalf("params mismatch: got allowed=%v reason=%s, want binding_mismatch", d.Allowed, d.Reason) } } func TestGate_WrongOpBindingMismatch(t *testing.T) { op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) g := NewGate(v, testHost, nil, nil) issued, expires := freshWindow() // a valid signature for restore_overwrite cannot authorize a guest_destroy. signed := op.mint("restore_overwrite", testHost, "9001", "op1", nonce(), `{"purge":true}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || d.Reason != ReasonBindingMismatch { t.Fatalf("op mismatch: got allowed=%v reason=%s, want binding_mismatch", d.Allowed, d.Reason) } } func TestGate_RecoveryKeyOnOrdinaryDestructiveRoleDenied(t *testing.T) { // A valid signature from the cold RECOVERY key on an ordinary destructive op is // refused by role-scoping (recovery authorizes ONLY key-rotation). rec := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, rec.allowed(t, "rec1", authz.RoleRecovery)) g := NewGate(v, testHost, nil, nil) issued, expires := freshWindow() signed := rec.mint("guest_destroy", testHost, "9001", "rec1", nonce(), `{"purge":true}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || d.Reason != ReasonRoleDenied { t.Fatalf("recovery on destroy: got allowed=%v reason=%s, want role_denied", d.Allowed, d.Reason) } } func TestGate_HubSuppliedScratchTagIgnored(t *testing.T) { // A compromised hub attaches a "scratch" hint to a data-bearing guest's destroy // delta to try to walk the gate unsigned. The intent built from a hub delta must // NOT carry that as agent-internal provenance — so it stays destructive and is // refused without a signature. intent := intentFromHubDelta(hubDelta{Class: ClassGuestDestroy, HostID: testHost, GuestID: "9001", VMID: 9001, HubSaysScratch: true}) if intent.Provenance.AgentTaggedScratch || intent.Provenance.SameTxnCreated { t.Fatal("hub-supplied scratch must NOT become agent-internal provenance") } g := NewGate(nil, testHost, nil, nil) // no verifier even needed d := g.Authorize(intent, nil) if d.Allowed || d.Reason != ReasonPendingSignature { t.Fatalf("hub-scratch destroy: got allowed=%v reason=%s, want pending_signature", d.Allowed, d.Reason) } } func TestGate_ValidOpAcceptedThenReplayRejected(t *testing.T) { // The ONE positive case: valid signature, correct role, correct target, fresh // nonce → accepted. A SECOND presentation (same nonce) → rejected (nonce consumed). op := newTestSigner(t) path := filepath.Join(t.TempDir(), "n.log") v, _ := realVerifierAt(t, path, testHost, op.allowed(t, "op1", authz.RoleOperational)) aud := &captureAudit{} g := NewGate(v, testHost, aud, nil) issued, expires := freshWindow() n := nonce() signed := op.mint("guest_destroy", testHost, "9001", "op1", n, `{"purge":true}`, issued, expires) d := g.Authorize(destroyIntent(SourceOneShotJob), signed) if !d.Allowed || d.Reason != ReasonSigned { t.Fatalf("valid op: got allowed=%v reason=%s err=%v, want accepted/signed", d.Allowed, d.Reason, d.Err) } if d.Verified == nil || d.Verified.Nonce != n { t.Fatalf("accepted op should surface the verified op with nonce %s", n) } // Replay the exact same signed op → nonce already consumed. d2 := g.Authorize(destroyIntent(SourceOneShotJob), signed) if d2.Allowed || !errors.Is(d2.Err, authz.ErrReplay) { t.Fatalf("replay: got allowed=%v err=%v, want ErrReplay", d2.Allowed, d2.Err) } } func TestGate_ReplayAcrossRestartRejected(t *testing.T) { // Replay protection must survive an agent restart (the durable nonce store). Accept // once with verifier A, then reopen the SAME nonce-store path as verifier B (a // restart) and replay → still rejected. op := newTestSigner(t) path := filepath.Join(t.TempDir(), "n.log") signer := op.allowed(t, "op1", authz.RoleOperational) issued, expires := freshWindow() n := nonce() signed := op.mint("guest_destroy", testHost, "9001", "op1", n, `{"purge":true}`, issued, expires) vA, storeA := realVerifierAt(t, path, testHost, signer) if d := NewGate(vA, testHost, nil, nil).Authorize(destroyIntent(SourceOneShotJob), signed); !d.Allowed { t.Fatalf("first presentation should be accepted: %+v", d) } storeA.Close() // simulate shutdown vB, _ := realVerifierAt(t, path, testHost, signer) // restart: reopen same nonce log d := NewGate(vB, testHost, nil, nil).Authorize(destroyIntent(SourceOneShotJob), signed) if d.Allowed || !errors.Is(d.Err, authz.ErrReplay) { t.Fatalf("replay across restart: got allowed=%v err=%v, want ErrReplay", d.Allowed, d.Err) } } // --- gate unit tests (benign path, binding, params) --- func TestGate_BenignAllowedWithoutVerifier(t *testing.T) { g := NewGate(nil, testHost, nil, nil) // no verifier at all for _, k := range []ActionKind{ActionStart, ActionStop, ActionSetConfig} { d := g.Authorize(intentForAction(testHost, Action{VMID: 100, Kind: k}), nil) if !d.Allowed || d.Reason != ReasonBenign { t.Errorf("benign %s: got allowed=%v reason=%s, want benign", k, d.Allowed, d.Reason) } } } func TestParamsEqual(t *testing.T) { eq := func(a, b string) bool { return paramsEqual(json.RawMessage(a), json.RawMessage(b)) } if !eq(`{"purge":true}`, `{"purge":true}`) { t.Error("identical params should be equal") } if !eq(`{"a":1,"b":2}`, `{"b":2,"a":1}`) { t.Error("key order must not matter") } if eq(`{"purge":true}`, `{"purge":false}`) { t.Error("different values must differ") } if !eq(``, `{}`) || !eq(`{}`, `null`) { t.Error("absent / empty / null params should all compare equal") } } // --- helpers for the hub-scratch test: a stand-in for the slice-10 desired-delta → // intent constructor, proving it never propagates hub-supplied provenance. --- type hubDelta struct { Class OpClass HostID string GuestID string VMID int HubSaysScratch bool // a hostile/erroneous hub hint — MUST be ignored } func intentFromHubDelta(d hubDelta) Intent { // NOTE: HubSaysScratch is deliberately NOT mapped to Provenance. Agent-internal // provenance (scratch/same-txn) is recorded by the agent's own journal, never taken // from the hub (doc 03 §4). return Intent{ Class: d.Class, HostID: d.HostID, GuestID: d.GuestID, VMID: d.VMID, Provenance: Provenance{}, // always zero from an external source Source: SourceDesiredDelta, } }