package hub import ( "encoding/json" "regexp" "testing" ) // secretNameRe matches any JSON key that smells like a credential. The DR recipe must contain NONE // (the Phase-1 lesson: the retired infra-backup shipped encryption_key_b64/restic_password/cf_api_token). // Mirrored on the controller app-half emitter (the heavier boundary test lives there). var secretNameRe = regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`) func TestBuildDRRecipeHostHalf(t *testing.T) { guests := []Guest{ {VMID: 9201, Name: "cust", Status: "running", Spec: &GuestSpec{Cores: 4, MemoryBytes: 12 << 30, DiskBytes: 32 << 30}}, {VMID: 9202, Name: "unknown", Status: "unknown"}, // nil Spec → skipped (no sizing) } targets := []StorageTarget{ {Name: "local", Type: StorageTypeLocal, Content: "vztmpl,iso"}, {Name: "local-lvm", Type: StorageTypeLVMThin, DurableID: "pve/data", Content: "rootdir,images"}, {Name: "felhom-pbs", Type: StorageTypePBS, DurableID: "repo+fp", Content: "backup"}, {Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data", MountPath: "/mnt/felhom-usb", TotalBytes: 931 << 30}, {Name: "felhom-flash", Type: StorageTypeLocalDir, DurableID: "uuid:81a26531", Role: "primary", MountPath: "/mnt/felhom-flash", TotalBytes: 119 << 30}, } pbs := []PBSSnapshot{ {Namespace: "root", BackupID: "9201", BackupTime: "2026-06-10T00:00:00Z"}, {Namespace: "root", BackupID: "9201", BackupTime: "2026-06-16T08:00:00Z"}, // latest } h := BuildDRRecipeHostHalf(guests, targets, pbs) if h.RecipeVersion != 1 { t.Errorf("recipe_version=%d, want 1", h.RecipeVersion) } // guests: only the spec'd one. if len(h.Guests) != 1 || h.Guests[0].VMID != 9201 || h.Guests[0].Cores != 4 || h.Guests[0].MemoryBytes != 12<<30 { t.Errorf("guests = %+v, want only vmid 9201 with its sizing", h.Guests) } // pve_storage: ALL five targets (the storage.cfg scaffolding). if len(h.PVEStorage) != 5 { t.Errorf("pve_storage len=%d, want 5 (every target)", len(h.PVEStorage)) } // drives: ONLY the two user-data drives (usb + local-dir with uuid + mount). NOT local/lvm/pbs. if len(h.Drives) != 2 { t.Fatalf("drives len=%d, want 2 user-data drives, got %+v", len(h.Drives), h.Drives) } byDur := map[string]DRDrive{} for _, d := range h.Drives { byDur[d.DurableID] = d if d.Intent != "enrolled" { t.Errorf("drive %s intent=%q, want enrolled", d.DurableID, d.Intent) } } if d, ok := byDur["uuid:da9e7089"]; !ok || d.MountPath != "/mnt/felhom-usb" || d.TotalBytes != 931<<30 { t.Errorf("felhom-usb drive wrong: %+v", d) } if _, ok := byDur["uuid:81a26531"]; !ok { t.Error("felhom-flash (local-dir user-data drive) missing from drives") } // pbs: latest snapshot's coords + the pbs storage id as repo_id. if h.PBS == nil || h.PBS.RepoID != "felhom-pbs" || h.PBS.Namespace != "root" || h.PBS.LatestSnapshotID != "9201" { t.Errorf("pbs coord = %+v, want repo felhom-pbs/root/9201", h.PBS) } } // TestBuildDRRecipeHostHalf_NoPBS: no snapshots → pbs omitted (nil), no panic. func TestBuildDRRecipeHostHalf_NoPBS(t *testing.T) { h := BuildDRRecipeHostHalf(nil, []StorageTarget{{Name: "local", Type: StorageTypeLocal}}, nil) if h.PBS != nil { t.Errorf("pbs should be nil with no snapshots, got %+v", h.PBS) } if h.Guests == nil || h.Drives == nil || h.PVEStorage == nil { t.Error("slices must be non-nil (marshal as [], not null)") } } // TestDRRecipeHostHalf_V1DriveShape pins the v1 host-half drive shape: a drive object carries ONLY // {durable_id, mount_path, intent, total_bytes} (fs_type is omitempty) — and specifically NEITHER the // dropped "role" NOR "restic_repo_coord" keys. Re-adding either field to DRDrive makes this fail // (the companion: `Role string \`json:"role"\“ reintroduces the "role" key → caught here). func TestDRRecipeHostHalf_V1DriveShape(t *testing.T) { h := BuildDRRecipeHostHalf( nil, []StorageTarget{ {Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data", MountPath: "/mnt/felhom-usb", TotalBytes: 931 << 30}, }, nil, ) if len(h.Drives) != 1 { t.Fatalf("want 1 drive, got %d", len(h.Drives)) } b, err := json.Marshal(h.Drives[0]) if err != nil { t.Fatal(err) } var keys map[string]json.RawMessage if err := json.Unmarshal(b, &keys); err != nil { t.Fatal(err) } for _, banned := range []string{"role", "restic_repo_coord"} { if _, ok := keys[banned]; ok { t.Errorf("v1 drive must NOT carry %q key (it was dropped); got %s", banned, b) } } for _, want := range []string{"durable_id", "mount_path", "intent", "total_bytes"} { if _, ok := keys[want]; !ok { t.Errorf("v1 drive missing required key %q; got %s", want, b) } } } // TestDRRecipeHostHalf_NoSecrets is the agent-side boundary assertion (the lighter mirror of the // controller's load-bearing boundary test): a fully-populated host-half must carry NO field whose // name smells like a credential. If a future field leaks a key/token/hash in, this fails. func TestDRRecipeHostHalf_NoSecrets(t *testing.T) { h := BuildDRRecipeHostHalf( []Guest{{VMID: 9201, Spec: &GuestSpec{Cores: 4, MemoryBytes: 1, DiskBytes: 1}}}, []StorageTarget{ {Name: "felhom-pbs", Type: StorageTypePBS, Content: "backup"}, {Name: "felhom-usb", Type: StorageTypeUSB, DurableID: "uuid:da9e7089", Role: "bulk-data", MountPath: "/mnt/felhom-usb", TotalBytes: 1}, }, []PBSSnapshot{{Namespace: "root", BackupID: "9201", BackupTime: "2026-06-16T08:00:00Z"}}, ) b, err := json.Marshal(h) if err != nil { t.Fatal(err) } assertNoSecretKeys(t, b) } // assertNoSecretKeys walks decoded JSON and fails on any object key matching secretNameRe. Shared by // the agent boundary assertions. (durable_id/repo_id/latest_snapshot_id are identifiers/coordinates — // none match the credential regex.) func assertNoSecretKeys(t *testing.T, jsonBytes []byte) { t.Helper() var v any if err := json.Unmarshal(jsonBytes, &v); err != nil { t.Fatal(err) } var walk func(prefix string, node any) walk = func(prefix string, node any) { switch n := node.(type) { case map[string]any: for k, child := range n { if secretNameRe.MatchString(k) { t.Errorf("secret-shaped key %q at %s — the recipe must carry no credential field", k, prefix) } walk(prefix+"."+k, child) } case []any: for i, child := range n { walk(prefix, child) _ = i } } } walk("", v) }