Files
felhom-controller/controller/internal/settings/backup_target_role_test.go
T
admin ff058a4f10 E-2 Part 1: the backup-target role on StoragePath (foundation, not yet wired)
Foundation only, no version bump -- nothing customer-visible changes yet. The
offer UI (Part 3), the degraded banner (Part 4) and the controller half of the
absent-target signal (Part 5) are NOT here; they are tracked as E-2 in
OPEN-ITEMS.md so this cannot become a sixth "seam built but never wired". The
fifth was found by E-2's own Phase 0: NotifyStorageDisconnected is defined and
called nowhere, so a drive going absent emits no event at all today.

StoragePath gains BackupTarget bool -- the sibling role to Schedulable/
IsDefault/Kind. It is INTENT, not truth: the authority is the agent's
backup.local_backup_target, and truth is read from GET /backup/tiers. This
records what the customer ASSIGNED so the controller can render the state,
notice the drive going absent, and detect drift.

Invariants, each pinned by a test asserting the CONSEQUENCE not the mechanism:
  - a drive NEVER acquires the role by appearing (registration does not set it)
  - exactly one carrier; assigning moves rather than duplicates
  - sticky: a new bigger/faster drive does not steal an assigned target
  - an absent target STAYS assigned -- clearing on disconnect would be a silent
    retarget by omission ("no target configured" instead of "drive missing")
  - a network share is refused (R-108 risk model; the role is about LOCAL disk
    failure)

Red-proof C: adding auto-elevation to AddStoragePath fails
TestRegisteringDrivesNeverAssignsTheBackupTarget with
  registering drives assigned the backup target "/mnt/hdd_1"

Attributes may suggest and refuse the absurd, never select: demo-felhom's backup
drive is an external USB HDD and BOTH demo boxes report removable=0, so a
transport rule disqualifies the reference drive and a removable rule finds no
candidate at all.

Green gate: build + vet + test rc=0, run separately from this commit.
2026-07-29 08:00:47 +02:00

122 lines
4.6 KiB
Go

package settings
import (
"log"
"os"
"path/filepath"
"testing"
)
func roleTestSettings(t *testing.T) *Settings {
t.Helper()
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0))
if err != nil {
t.Fatalf("load: %v", err)
}
return s
}
// SCENARIO C, the core of E-2 §3: a drive NEVER acquires the backup-target role by appearing.
//
// This is the invariant the whole role model exists for. Registration is the path a newly-plugged
// drive travels, and the failure it guards against is a USB stick silently becoming the destination
// of the whole-system backup. The test asserts the CONSEQUENCE (no target is assigned after
// registering drives) rather than the mechanism, so it still fails if a future caller sets the field
// through some other route in the registration path.
func TestRegisteringDrivesNeverAssignsTheBackupTarget(t *testing.T) {
s := roleTestSettings(t)
for _, p := range []string{"/mnt/hdd_1", "/mnt/usb_stick", "/mnt/nvme-1tb"} {
if err := s.AddStoragePath(StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
t.Fatalf("add %s: %v", p, err)
}
}
if got := s.BackupTargetPath(); got != "" {
t.Fatalf("registering drives assigned the backup target %q — a drive must never acquire a role "+
"by appearing (E-2 §3); only an explicit customer choice may assign it", got)
}
if s.BackupTargetAssigned() {
t.Fatal("BackupTargetAssigned() is true after registration alone")
}
}
// Exactly one target at a time — assigning a second must move the role, not duplicate it. Two paths
// both claiming to be the destination is not a cosmetic defect: the renderer and the absent-check
// would each pick a different one.
func TestBackupTargetIsExclusive(t *testing.T) {
s := roleTestSettings(t)
for _, p := range []string{"/mnt/a", "/mnt/b"} {
if err := s.AddStoragePath(StoragePath{Path: p, Schedulable: true}); err != nil {
t.Fatalf("add: %v", err)
}
}
if err := s.SetBackupTarget("/mnt/a"); err != nil {
t.Fatalf("set a: %v", err)
}
if err := s.SetBackupTarget("/mnt/b"); err != nil {
t.Fatalf("set b: %v", err)
}
var carriers []string
for _, sp := range s.GetStoragePaths() {
if sp.BackupTarget {
carriers = append(carriers, sp.Path)
}
}
if len(carriers) != 1 || carriers[0] != "/mnt/b" {
t.Fatalf("expected exactly one carrier (/mnt/b), got %v", carriers)
}
}
// STICKINESS (E-2 §3): a new drive appearing must not move an assigned target. This is the other half
// of "never by appearing" — the first covers an unassigned box, this covers an assigned one.
func TestNewDriveDoesNotStealAnAssignedTarget(t *testing.T) {
s := roleTestSettings(t)
if err := s.AddStoragePath(StoragePath{Path: "/mnt/hdd_1", Schedulable: true}); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.SetBackupTarget("/mnt/hdd_1"); err != nil {
t.Fatalf("set: %v", err)
}
// A bigger, faster, newer drive appears. It must change nothing.
if err := s.AddStoragePath(StoragePath{Path: "/mnt/nvme_huge", Schedulable: true}); err != nil {
t.Fatalf("add: %v", err)
}
if got := s.BackupTargetPath(); got != "/mnt/hdd_1" {
t.Fatalf("target moved to %q when a new drive appeared — the role is sticky", got)
}
}
// An ABSENT target stays assigned. Clearing it on disconnect would be a silent retarget by omission:
// the box would read as "no target configured" (degraded) instead of "your target drive is missing"
// (alarm), and reconnecting would not restore the assignment.
func TestDisconnectDoesNotClearTheTarget(t *testing.T) {
s := roleTestSettings(t)
if err := s.AddStoragePath(StoragePath{Path: "/mnt/hdd_1", Schedulable: true}); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.SetBackupTarget("/mnt/hdd_1"); err != nil {
t.Fatalf("set: %v", err)
}
if err := s.SetDisconnected("/mnt/hdd_1", true, nil); err != nil {
t.Fatalf("disconnect: %v", err)
}
if got := s.BackupTargetPath(); got != "/mnt/hdd_1" {
t.Fatalf("disconnect cleared the target (got %q) — an absent target must stay assigned and "+
"alarm, never silently become 'unconfigured'", got)
}
}
// A network share can never be the target: the role exists to survive a LOCAL disk failure, and a
// remote, credential-bound share mounted at its own root (R-108) is a different risk model.
func TestNetworkShareRefusedAsBackupTarget(t *testing.T) {
s := roleTestSettings(t)
if err := s.AddStoragePath(StoragePath{Path: "/mnt/felhom-drives/nas", Kind: StorageKindNetwork}); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.SetBackupTarget("/mnt/felhom-drives/nas"); err == nil {
t.Fatal("a network share was accepted as the backup target")
}
if s.BackupTargetAssigned() {
t.Fatal("a refused assignment still set the role")
}
}