ff058a4f10
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.
122 lines
4.6 KiB
Go
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")
|
|
}
|
|
}
|