package reconcile import ( "context" "encoding/json" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/authz" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // --- The disk-grow executor (deferred from slice 4): grow applies, shrink refused. --- func TestPlan_DiskGrowOnly(t *testing.T) { norm := DefaultNormalizers() t.Run("grow emits a resize action", func(t *testing.T) { desired := DesiredState{Guests: map[int]DesiredGuest{ 100: {VMID: 100, Spec: &hub.GuestSpec{DiskBytes: 20 << 30}}, // want 20 GiB }} actual := ActualState{Guests: map[int]ActualGuest{ 100: {VMID: 100, SpecKnown: true, DiskBytes: 8 << 30}, // have 8 GiB }} var resize *Action for _, a := range Plan(desired, actual, norm) { if a.Kind == ActionResize { a := a resize = &a } } if resize == nil { t.Fatal("expected a resize action for a grow") } if resize.Params["disk"] != "rootfs" || resize.Params["size"] != "+12288M" { t.Errorf("resize params = %v, want disk=rootfs size=+12288M", resize.Params) } }) t.Run("shrink is refused (no action)", func(t *testing.T) { desired := DesiredState{Guests: map[int]DesiredGuest{100: {VMID: 100, Spec: &hub.GuestSpec{DiskBytes: 4 << 30}}}} actual := ActualState{Guests: map[int]ActualGuest{100: {VMID: 100, SpecKnown: true, DiskBytes: 8 << 30}}} for _, a := range Plan(desired, actual, norm) { if a.Kind == ActionResize { t.Fatalf("a data-losing shrink must NOT be planned as a resize: %+v", a) } } }) t.Run("equal size is a no-op", func(t *testing.T) { desired := DesiredState{Guests: map[int]DesiredGuest{100: {VMID: 100, Spec: &hub.GuestSpec{DiskBytes: 8 << 30}}}} actual := ActualState{Guests: map[int]ActualGuest{100: {VMID: 100, SpecKnown: true, DiskBytes: 8 << 30}}} for _, a := range Plan(desired, actual, norm) { if a.Kind == ActionResize { t.Fatalf("equal disk size must not resize: %+v", a) } } }) } func TestEngine_GrowExecutes_NonGrowRefusedAtExecutor(t *testing.T) { a := &fakeAPI{resizeUPID: "UPID:resize:1"} e, _, q := newEngine(t, a, EmptyProvider{}) defer q.Close() // A grow applies (ResizeLXC called with the grow size). if err := e.execute(context.Background(), Action{VMID: 100, Kind: ActionResize, Params: map[string]string{"disk": "rootfs", "size": "+12288M"}}); err != nil { t.Fatalf("grow execute: %v", err) } if len(a.resizes) != 1 || a.resizes[0].size != "+12288M" || a.resizes[0].disk != "rootfs" { t.Fatalf("ResizeLXC not called correctly: %+v", a.resizes) } // A non-grow ("absolute"/shrink) size is refused at the executor and never hits the API. a.resizes = nil if err := e.execute(context.Background(), Action{VMID: 100, Kind: ActionResize, Params: map[string]string{"disk": "rootfs", "size": "4G"}}); err == nil { t.Fatal("a non-grow resize size must be refused at the executor") } if len(a.resizes) != 0 { t.Fatalf("refused resize must not call the API: %+v", a.resizes) } } // --- Destructive storage ops through the slice-4 gate (real verifier). --- func wipeIntent(targetID string) Intent { return IntentForStorageDestructive(ClassStorageWipe, testHost, targetID, json.RawMessage(`{"wipe":true}`), SourceOneShotJob) } func TestGate_StorageWipeUnsignedPendingSignature(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(wipeIntent("usb-backup"), nil) if d.Allowed || d.Reason != ReasonPendingSignature { t.Fatalf("unsigned storage wipe: got allowed=%v reason=%s, want pending_signature", d.Allowed, d.Reason) } if len(aud.recs) != 1 || aud.recs[0].Allowed { t.Errorf("refused wipe must be audited: %+v", aud.recs) } } func TestGate_StorageWipeWrongTargetBindingMismatch(t *testing.T) { // A valid signature authorizing "wipe target A" must NOT authorize "wipe target B" — // the op-to-action binding rejects it (the storage analog of the wrong-guest case). 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("storage_wipe", testHost, "usb-backup", "op1", nonce(), `{"wipe":true}`, issued, expires) d := g.Authorize(wipeIntent("nfs-arch"), signed) // action targets a DIFFERENT store if d.Allowed || d.Reason != ReasonBindingMismatch { t.Fatalf("wrong-target wipe: got allowed=%v reason=%s, want binding_mismatch", d.Allowed, d.Reason) } } func TestGate_StorageWipeValidAccepted(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("storage_wipe", testHost, "usb-backup", "op1", nonce(), `{"wipe":true}`, issued, expires) d := g.Authorize(wipeIntent("usb-backup"), signed) if !d.Allowed || d.Reason != ReasonSigned { t.Fatalf("valid storage wipe: got allowed=%v reason=%s err=%v, want accepted/signed", d.Allowed, d.Reason, d.Err) } } func TestGate_StorageMountBenign(t *testing.T) { // A re-mount is benign by classification → allowed unsigned (no verifier needed). g := NewGate(nil, testHost, nil, nil) d := g.Authorize(IntentForStorageMount(testHost, "usb-backup"), nil) if !d.Allowed || d.Reason != ReasonBenign { t.Fatalf("storage mount: got allowed=%v reason=%s, want benign", d.Allowed, d.Reason) } } // TestRunSignedJob_StorageDestructiveExecutes proves the authorized destructive-storage path // reaches its executor (inert live — wired in main.go later; here a fake exec records it). func TestRunSignedJob_StorageDestructiveExecutes(t *testing.T) { op := newTestSigner(t) v, _ := realVerifierAt(t, filepath.Join(t.TempDir(), "n.log"), testHost, op.allowed(t, "op1", authz.RoleOperational)) a := &fakeAPI{} q := NewQueue() t.Cleanup(q.Close) e := NewEngine(EngineOptions{API: a, Queue: q, Gate: NewGate(v, testHost, nil, nil), HostID: testHost}) issued, expires := freshWindow() signed := op.mint("storage_wipe", testHost, "usb-backup", "op1", nonce(), `{"wipe":true}`, issued, expires) var ran bool exec := func(_ context.Context, intent Intent, _ *authz.VerifiedOp) (string, error) { ran = true if intent.GuestID != "usb-backup" { t.Errorf("executor got wrong target %q", intent.GuestID) } return "", nil } res := e.RunSignedJob(context.Background(), wipeIntent("usb-backup"), signed, exec) if !res.Executed || !ran || res.Err != nil { t.Fatalf("authorized storage wipe should execute cleanly: %+v ran=%v", res, ran) } }