v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
|
||||
@@ -24,21 +25,27 @@ func TestTemplatesParse(t *testing.T) {
|
||||
|
||||
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
|
||||
type mockAgent struct {
|
||||
disks agentapi.DisksResponse
|
||||
formatRes agentapi.FormatResult
|
||||
formatErr error
|
||||
assignErr error
|
||||
assignCalls []assignCall
|
||||
disksCalls int
|
||||
disks agentapi.DisksResponse
|
||||
formatRes agentapi.FormatResult
|
||||
formatErr error
|
||||
assignErr error
|
||||
assignCalls []assignCall
|
||||
disksCalls int
|
||||
formatCalls []formatCall
|
||||
}
|
||||
|
||||
type assignCall struct{ uuid, where, fstype string }
|
||||
type formatCall struct {
|
||||
device, fstype, durableID string
|
||||
confirmed bool
|
||||
}
|
||||
|
||||
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
|
||||
m.disksCalls++
|
||||
return m.disks, nil
|
||||
}
|
||||
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) {
|
||||
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) {
|
||||
m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed})
|
||||
return m.formatRes, m.formatErr
|
||||
}
|
||||
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
|
||||
@@ -59,22 +66,23 @@ func testServer(t *testing.T) *Server {
|
||||
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
|
||||
}
|
||||
|
||||
// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register.
|
||||
func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
|
||||
// SECURITY: a SYSTEM/BACKUP data-bearing refusal must surface the opsign command and perform NO
|
||||
// assign/register (operator signature required — confirmation cannot help).
|
||||
func TestRunStorageInit_SystemBackupRefusal(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &mockAgent{
|
||||
formatErr: agentapi.ErrFormatRefused,
|
||||
formatRes: agentapi.FormatResult{
|
||||
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
|
||||
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", Role: "system",
|
||||
PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"},
|
||||
},
|
||||
}
|
||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true)
|
||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Refused {
|
||||
t.Fatal("expected Refused=true on a data-bearing device")
|
||||
t.Fatal("expected Refused=true on a protected data-bearing device")
|
||||
}
|
||||
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
|
||||
t.Errorf("opsign command not surfaced: %q", res.Opsign)
|
||||
@@ -87,6 +95,54 @@ func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A USER-DATA data-bearing device returns NeedsConfirmation (+ the durable id to confirm against) and
|
||||
// performs NO assign/register — the customer must confirm the wipe first (NOT an operator signature).
|
||||
func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &mockAgent{
|
||||
formatErr: agentapi.ErrNeedsConfirmation,
|
||||
formatRes: agentapi.FormatResult{
|
||||
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
|
||||
Role: "user-data", DurableID: "byid:wwn-abc",
|
||||
},
|
||||
}
|
||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-abc" || res.Role != "user-data" {
|
||||
t.Fatalf("expected NeedsConfirmation with the durable id + role: %+v", res)
|
||||
}
|
||||
if res.Refused || res.Opsign != "" {
|
||||
t.Fatal("a user-data device must NOT surface an operator-signature path")
|
||||
}
|
||||
if len(agent.assignCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 {
|
||||
t.Fatal("NeedsConfirmation MUST NOT mount or register")
|
||||
}
|
||||
}
|
||||
|
||||
// After the customer confirms, the wizard re-submits with confirmed=true + the durable id; the format
|
||||
// then succeeds and the flow proceeds to assign + register. Assert the confirmation is forwarded.
|
||||
func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &mockAgent{
|
||||
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: true, Role: "user-data"},
|
||||
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
|
||||
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"},
|
||||
}},
|
||||
}
|
||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.Registered {
|
||||
t.Fatalf("confirmed wipe should proceed to register: %+v", res)
|
||||
}
|
||||
if len(agent.formatCalls) != 1 || !agent.formatCalls[0].confirmed || agent.formatCalls[0].durableID != "byid:wwn-abc" {
|
||||
t.Fatalf("the customer confirmation + durable id were not forwarded to the agent: %+v", agent.formatCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
|
||||
func TestRunStorageInit_Success(t *testing.T) {
|
||||
s := testServer(t)
|
||||
@@ -96,7 +152,7 @@ func TestRunStorageInit_Success(t *testing.T) {
|
||||
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"},
|
||||
}},
|
||||
}
|
||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true)
|
||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -148,6 +204,39 @@ func TestFSUUIDForDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dependency-impact: name the deployed apps whose data lives on a given mount (the type-to-confirm
|
||||
// "which apps break" list). Pure helper so no live stacks.Manager is needed.
|
||||
func TestAppsUsingPathIn(t *testing.T) {
|
||||
all := []stacks.Stack{
|
||||
{Name: "immich", Deployed: true, Meta: stacks.Metadata{DisplayName: "Immich"}},
|
||||
{Name: "nextcloud", Deployed: true, Meta: stacks.Metadata{DisplayName: "Nextcloud"}},
|
||||
{Name: "paperless", Deployed: true, Meta: stacks.Metadata{DisplayName: "Paperless"}},
|
||||
{Name: "notdeployed", Deployed: false, Meta: stacks.Metadata{DisplayName: "Nem telepített"}},
|
||||
}
|
||||
env := map[string]map[string]string{
|
||||
"immich": {"HDD_PATH": "/mnt/hdd_1"},
|
||||
"nextcloud": {"HDD_PATH": "/mnt/hdd_1"},
|
||||
"paperless": {"HDD_PATH": "/mnt/hdd_2"}, // different drive
|
||||
"notdeployed": {"HDD_PATH": "/mnt/hdd_1"}, // on the drive but not deployed → excluded
|
||||
}
|
||||
load := func(name string) *stacks.AppConfig {
|
||||
if e, ok := env[name]; ok {
|
||||
return &stacks.AppConfig{Env: e}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
got := appsUsingPathIn(all, load, "/mnt/hdd_1")
|
||||
if len(got) != 2 || got[0] != "Immich" || got[1] != "Nextcloud" {
|
||||
t.Fatalf("apps on /mnt/hdd_1: got %v, want [Immich Nextcloud]", got)
|
||||
}
|
||||
if other := appsUsingPathIn(all, load, "/mnt/hdd_2"); len(other) != 1 || other[0] != "Paperless" {
|
||||
t.Fatalf("apps on /mnt/hdd_2: got %v, want [Paperless]", other)
|
||||
}
|
||||
if none := appsUsingPathIn(all, load, "/mnt/empty"); len(none) != 0 {
|
||||
t.Fatalf("apps on an unused mount: got %v, want []", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountWhere(t *testing.T) {
|
||||
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
|
||||
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
|
||||
|
||||
Reference in New Issue
Block a user