Files
felhom-agent/internal/localapi/disks_test.go
T

363 lines
14 KiB
Go

package localapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// ---- fakes ------------------------------------------------------------------------------
type fakeDiskOps struct {
mu sync.Mutex
probe storage.DeviceProbe // returned by InspectDevice (Device filled per call)
inspectErr error
formatCalls []string
mountCalls []storage.MountSpec
unmountCalls []string
}
func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
p := f.probe
p.Device = device
return p, f.inspectErr
}
func (f *fakeDiskOps) Format(_ context.Context, device, _ string) error {
f.mu.Lock()
f.formatCalls = append(f.formatCalls, device)
f.mu.Unlock()
return nil
}
func (f *fakeDiskOps) EnsureMount(_ context.Context, spec storage.MountSpec) error {
f.mu.Lock()
f.mountCalls = append(f.mountCalls, spec)
f.mu.Unlock()
return nil
}
func (f *fakeDiskOps) Unmount(_ context.Context, where string) error {
f.mu.Lock()
f.unmountCalls = append(f.unmountCalls, where)
f.mu.Unlock()
return nil
}
func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
type fakeGate struct {
mu sync.Mutex
decision WipeDecision
reqs []WipeRequest
}
func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision {
g.mu.Lock()
defer g.mu.Unlock()
g.reqs = append(g.reqs, req)
return g.decision
}
func (g *fakeGate) requests() []WipeRequest {
g.mu.Lock()
defer g.mu.Unlock()
return append([]WipeRequest(nil), g.reqs...)
}
type fakeGuestList struct{ guests []proxmox.Guest }
func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.guests, nil }
// newDiskServer builds a server wired with the 8C disk deps (token A → guest 8200).
func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl GuestLister) http.Handler {
t.Helper()
if sv == nil {
sv = fakeStorage{}
}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: sv,
Tokens: staticTokens{"A": 8200, "B": 9300},
Disks: d,
DiskGate: g,
Guests2: gl,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
// ---- the security centerpiece -----------------------------------------------------------
// A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
func TestFormat_BlankDevice_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set
g := &fakeGate{}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusOK {
t.Fatalf("blank format: got %d want 200 (%s)", w.Code, w.Body.String())
}
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for blank device: %v", got)
}
if len(g.requests()) != 0 {
t.Fatal("gate was consulted for a blank-device format (should be benign)")
}
}
// THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as
// SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the
// response surfaces the operator-signature pending op (NOT a customer-confirmation prompt).
func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("data-bearing protected format: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs WAS called on a protected data-bearing device — security invariant violated: %v", got)
}
if len(g.requests()) != 1 {
t.Fatalf("gate not consulted for the destructive format: %v", g.requests())
}
if !strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String())
}
}
// A hand-issued confirmed:true on a data-bearing device the gate tiers as SYSTEM/BACKUP is STILL
// refused (operator-signature) and mkfs is NOT called — role beats confirmation. (The gate's
// role-tiering is itself asserted in reconcile/gate_test; here we assert the handler honors a
// destructive verdict even when the caller claimed confirmed:true.)
func TestFormat_DataBearing_ConfirmedTrueButProtected_StillRefused(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byid:wwn-xyz"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("confirmed-but-protected format: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs called despite protected role — confirmation must not beat role: %v", got)
}
// The handler must have forwarded the caller's confirmation claim to the gate (the gate, not the
// handler, ignores it for protected roles).
if reqs := g.requests(); len(reqs) != 1 || !reqs[0].Confirmed || reqs[0].ConfirmDurableID != "byid:wwn-xyz" {
t.Fatalf("handler did not forward the confirmation claim to the gate: %+v", reqs)
}
}
// USER-DATA + customer-confirmed (gate Allowed): the wipe proceeds — mkfs IS called, 200.
func TestFormat_DataBearing_UserDataConfirmed_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byuuid:abc"}`)
if w.Code != http.StatusOK {
t.Fatalf("user-data confirmed format: got %d want 200 (%s)", w.Code, w.Body.String())
}
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for a customer-confirmed user-data wipe: %v", got)
}
}
// USER-DATA + NOT yet confirmed (gate refuses pending_confirmation): 403 with needs_confirmation,
// mkfs NOT called, and NO operator-signature pending op (it's the customer's to confirm).
func TestFormat_DataBearing_UserDataNeedsConfirmation(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "customer_confirmable", Reason: "pending_confirmation", NeedsConfirmation: true}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("user-data unconfirmed: got %d want 403", w.Code)
}
if len(d.formatted()) != 0 {
t.Fatal("mkfs called for an unconfirmed user-data wipe")
}
var resp struct {
Data FormatResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Data.NeedsConfirmation {
t.Fatalf("response missing needs_confirmation: %s", w.Body.String())
}
if resp.Data.PendingOp != nil {
t.Fatal("user-data refusal must NOT surface an operator-signature pending op")
}
if strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("user-data refusal must not ask for an operator signature: %s", w.Body.String())
}
}
// Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as
// data-bearing → routed through the gate, mkfs not called.
func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("ambiguous device: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs called on an unprobed device: %v", got)
}
}
func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/../etc","fstype":"ext4"}`); w.Code != http.StatusBadRequest {
t.Fatalf("bad device: got %d want 400", w.Code)
}
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ntfs"}`); w.Code != http.StatusBadRequest {
t.Fatalf("bad fstype: got %d want 400", w.Code)
}
if len(d.formatted()) != 0 {
t.Fatal("formatted despite invalid input")
}
}
// ---- assign / eject ---------------------------------------------------------------------
func TestAssign_EnsureMount(t *testing.T) {
d := &fakeDiskOps{}
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
w := do(t, h, "POST", "/disks/assign", "A", `{"uuid":"1234-ABCD","where":"/mnt/data","fstype":"ext4"}`)
if w.Code != http.StatusOK {
t.Fatalf("assign: got %d (%s)", w.Code, w.Body.String())
}
d.mu.Lock()
defer d.mu.Unlock()
if len(d.mountCalls) != 1 || d.mountCalls[0].Where != "/mnt/data" || d.mountCalls[0].UUID != "1234-ABCD" {
t.Fatalf("EnsureMount not called correctly: %+v", d.mountCalls)
}
}
func TestEject_UnmountAndDependents(t *testing.T) {
d := &fakeDiskOps{}
// storage view: target "bulk" is mounted at /mnt/bulk
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", MountPath: "/mnt/bulk"}}}
// guest 8200 mounts storage "bulk"; guest 9300 does not
gl := fakeGuestList{guests: []proxmox.Guest{{VMID: 8200}, {VMID: 9300}}}
h := newDiskServerWithGuestConfigs(t, d, sv, gl, map[int]map[string]string{
8200: {"mp0": "bulk:200,mp=/mnt/media,backup=0"},
9300: {"mp0": "local-lvm:8,mp=/var,backup=1"},
})
w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`)
if w.Code != http.StatusOK {
t.Fatalf("eject: got %d (%s)", w.Code, w.Body.String())
}
d.mu.Lock()
unmounts := append([]string(nil), d.unmountCalls...)
d.mu.Unlock()
if len(unmounts) != 1 || unmounts[0] != "/mnt/bulk" {
t.Fatalf("Unmount not called: %v", unmounts)
}
var resp struct {
Data struct {
DependentGuests []int `json:"dependent_guests"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
found := false
for _, v := range resp.Data.DependentGuests {
if v == 8200 {
found = true
}
if v == 9300 {
t.Fatal("9300 listed as dependent but it does not mount bulk")
}
}
if !found {
t.Fatalf("dependent guest 8200 not reported: %v", resp.Data.DependentGuests)
}
}
// ---- auth / config ----------------------------------------------------------------------
func TestDisks_CrossGuest403(t *testing.T) {
h := newDiskServer(t, &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}, &fakeGate{}, nil, nil)
if w := do(t, h, "GET", "/disks?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest /disks: got %d want 403", w.Code)
}
if w := do(t, h, "POST", "/disks/format", "A", `{"vmid":9300,"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest format: got %d want 403", w.Code)
}
}
func TestDisks_NotConfigured(t *testing.T) {
// a server with no disk deps → 503 on disk endpoints
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{},
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
h := srv.Handler()
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured format: got %d want 503", w.Code)
}
}
// ---- helpers ----------------------------------------------------------------------------
// newDiskServerWithGuestConfigs is like newDiskServer but with a fakeGuests that returns the given
// per-vmid mount maps (so eject's dependent-scan can resolve).
func newDiskServerWithGuestConfigs(t *testing.T, d *fakeDiskOps, sv StorageView, gl GuestLister, mounts map[int]map[string]string) http.Handler {
t.Helper()
fg := &fakeGuestsCfg{mounts: mounts}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300},
Disks: d, DiskGate: &fakeGate{}, Guests2: gl,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
// fakeGuestsCfg returns per-vmid mountpoints from GuestConfig.
type fakeGuestsCfg struct{ mounts map[int]map[string]string }
func (f *fakeGuestsCfg) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
extra := map[string]json.RawMessage{}
for k, v := range f.mounts[vmid] {
b, _ := json.Marshal(v)
extra[k] = b
}
return proxmox.GuestConfig{Extra: extra}, nil
}
func (f *fakeGuestsCfg) Snapshot(context.Context, int, string, string) (string, error) { return "", nil }
func (f *fakeGuestsCfg) Rollback(context.Context, int, string) (string, error) { return "", nil }
func (f *fakeGuestsCfg) WaitTask(context.Context, string, proxmox.WaitOptions) (proxmox.TaskStatus, error) {
return proxmox.TaskStatus{ExitStatus: "OK"}, nil
}