Files
felhom.eu/hub/internal/store/dr_recipe_test.go
T
admin acfc2b7e95 R-109 + R-122: the recipe assembly stops dropping sections (hub v0.83.0)
AssembleDRRecipe's hostHalfShape/appHalfShape are ALLOW-LISTS, not the
forward-compat their comment advertised: a section an emitter adds is silently
discarded until it is named in both the shape struct and AssembledRecipe. No
error, no log, no failing test.

R-122 (found this session): that already happened and shipped. The controller
has emitted offsite_restic since fork-4 — the offsite recovery LOCATION — the
hub stored it for all three real customers, and appHalfShape never listed the
key, so no delivered recipe has ever contained it. It stayed green because the
fixture drAppHalf is hand-written and omits the field.

R-109: the agent's new backup_target is a new top-level host-half section and
would have been dropped identically, making the fix read as shipped while
changing nothing an operator can see.

3 tests built on halves read verbatim out of the live dr_recipe table, plus
2 red-proofs (each mutation asserted to have landed). vet rc=0, suite rc=0, 17 ok.

Registers: R-106 + R-109 dispositioned; R-105/R-106 were READY in ROADMAP with
no OPEN-ITEMS row (→ R-123, registered); R-124 filed on the "root" spelling.
2026-07-30 13:13:56 +02:00

354 lines
16 KiB
Go

package store
import (
"encoding/json"
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
// The two halves as the agent (host) and controller (app) emit them — keys must match the cross-repo
// golden (the agent's host-report.golden.json dr_recipe section + the controller's emitter).
const drHostHalf = `{
"recipe_version": 1,
"guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
"drives": [ { "durable_id": "uuid:da9e7089-cf8e-4617-adcb-a377743fae00", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
"pve_storage": [ { "name": "local-lvm", "type": "lvmthin", "content": "rootdir,images" }, { "name": "felhom-usb", "type": "usb", "content": "backup" } ],
"backup_target": { "state": "resolved", "storage_id": "felhom-usb", "mount_path": "/mnt/felhom-usb" }
}`
const drAppHalf = `{
"recipe_version": 1,
"customer": { "id": "cust-demo", "display": "Demo Customer", "domain": "demo-felhom.eu" },
"apps": [ { "catalog_ref": "romm", "enabled": true, "storage_bindings": [ { "container_path": "/roms", "drive": "felhom-flash", "subpath": "userdata/roms" } ] } ],
"offsite_restic": { "host": "u629488-sub1.your-storagebox.de", "user": "u629488-sub1", "port": 23, "repo_path": "/home/felhom-repo" }
}`
// capturedHostHalf / capturedAppHalf are the halves demo-felhom's controller and agent REALLY stored,
// read verbatim out of the hub's own dr_recipe table on 2026-07-30 (customer_id='demo-felhom'), with only
// the app list truncated for length. They exist because drHostHalf/drAppHalf above are hand-written and
// OMIT sections production supplies — and that omission is exactly why R-122 survived: `offsite_restic`
// was stored for every real customer and silently dropped by the assembly, with a green suite throughout.
//
// capturedHostHalf carries the POST-FIX host half (agent v0.118.0: backup_target + namespace_state); the
// pre-fix one is quoted in the CHANGELOG entry for comparison.
const capturedHostHalf = `{
"recipe_version": 1,
"guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
"pbs": { "repo_id": "felhom-pbs", "namespace": "demo-felhom", "namespace_state": "resolved", "latest_snapshot_id": "9201" },
"drives": [ { "durable_id": "uuid:47a3361a-91e0-4831-a69d-27f540ed3f48", "mount_path": "/mnt/hdd_1", "intent": "enrolled", "total_bytes": 983351140352 } ],
"pve_storage": [
{ "name": "local-lvm", "type": "lvmthin", "content": "images,rootdir" },
{ "name": "felhom-backup", "type": "local-dir", "content": "backup" },
{ "name": "felhom-pbs", "type": "pbs", "content": "backup" },
{ "name": "local", "type": "local", "content": "backup,import,vztmpl,iso" }
],
"backup_target": { "state": "resolved", "storage_id": "felhom-backup", "mount_path": "/mnt/hdd_1" }
}`
const capturedAppHalf = `{
"recipe_version": 1,
"customer": { "id": "demo-felhom", "display": "Demo Ügyfél", "domain": "demo-felhom.eu" },
"apps": [
{ "catalog_ref": "bookstack", "enabled": true, "storage_bindings": [] },
{ "catalog_ref": "immich", "enabled": true, "storage_bindings": [
{ "container_path": "/external/photos", "drive": "hdd_1", "subpath": "userdata/media/photos" } ] }
],
"offsite_restic": { "host": "u629488-sub1.your-storagebox.de", "user": "u629488-sub1", "port": 23, "repo_path": "/home/felhom-repo" }
}`
// TestDRRecipe_StoreRoundTrip: each half upserts independently and preserves the other; GetDRRecipe
// returns both.
func TestDRRecipe_StoreRoundTrip(t *testing.T) {
s := newTestStore(t)
// App half lands first.
if err := s.SaveDRRecipeAppHalf("cust-demo", 1, []byte(drAppHalf)); err != nil {
t.Fatal(err)
}
rec, _ := s.GetDRRecipe("cust-demo")
if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON != "" {
t.Fatalf("after app-half: want app set, host empty, got %+v", rec)
}
// Host half lands later — must NOT clobber the app half.
if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
t.Fatal(err)
}
rec, _ = s.GetDRRecipe("cust-demo")
if rec == nil || rec.AppHalfJSON == "" || rec.HostHalfJSON == "" || rec.HostID != "host-01" {
t.Fatalf("after host-half: both halves must be present + host_id set, got %+v", rec)
}
// A re-report of the host half preserves the app half (and vice-versa).
if err := s.SaveDRRecipeHostHalf("cust-demo", "host-01", 1, []byte(drHostHalf)); err != nil {
t.Fatal(err)
}
rec, _ = s.GetDRRecipe("cust-demo")
if rec.AppHalfJSON == "" {
t.Fatal("re-reporting the host half clobbered the app half")
}
// Absent customer → nil, no error.
if got, err := s.GetDRRecipe("nobody"); err != nil || got != nil {
t.Fatalf("absent customer should be (nil,nil), got (%v,%v)", got, err)
}
}
// TestAssembleDRRecipe_MatchesGolden: assembling both halves yields the golden's key shape (the
// cross-repo wire pin) and the correct stitched values.
func TestAssembleDRRecipe_MatchesGolden(t *testing.T) {
rec := &DRRecipe{CustomerID: "cust-demo", RecipeVersion: 1, HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf}
asm, err := AssembleDRRecipe(rec)
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(asm)
var got map[string]any
json.Unmarshal(b, &got)
raw, err := os.ReadFile("testdata/dr-recipe.golden.json")
if err != nil {
t.Fatal(err)
}
var golden map[string]any
if err := json.Unmarshal(raw, &golden); err != nil {
t.Fatalf("golden invalid: %v", err)
}
// Top-level key set must match the golden (the assembled wire shape).
if ga, gb := keysOf(golden), keysOf(got); !reflect.DeepEqual(ga, gb) {
t.Errorf("assembled key drift:\n golden=%v\n got =%v", ga, gb)
}
// And the stitched values: customer from the app half, drives/pbs from the host half.
if asm.RecipeVersion != 1 {
t.Errorf("recipe_version=%d want 1", asm.RecipeVersion)
}
if !jsonContains(t, asm.Customer, "cust-demo") || !jsonContains(t, asm.Customer, "demo-felhom.eu") {
t.Errorf("customer not stitched from app half: %s", asm.Customer)
}
if !jsonContains(t, asm.Drives, "uuid:da9e7089-cf8e-4617-adcb-a377743fae00") {
t.Errorf("drives not stitched from host half: %s", asm.Drives)
}
if !jsonContains(t, asm.Apps, "romm") {
t.Errorf("apps not stitched from app half: %s", asm.Apps)
}
}
// TestAssembleDRRecipe_V1DriveShape is the regression guard for the v1 host-half drive shape
// (agent v0.39.0 dropped role + restic_repo_coord): a stored host half whose drives carry NEITHER
// field, but WHICH HAS a pbs block, must assemble cleanly — pbs present, drives passed through
// verbatim (RawMessage passthrough means the hub needs no struct change for the dropped fields).
func TestAssembleDRRecipe_V1DriveShape(t *testing.T) {
const v1Host = `{
"recipe_version": 1,
"guests": [ { "vmid": 9201, "cores": 4, "memory_bytes": 12884901888, "disk_bytes": 34359738368 } ],
"pbs": { "repo_id": "felhom-pbs", "namespace": "root", "latest_snapshot_id": "9201" },
"drives": [ { "durable_id": "uuid:da9e7089", "mount_path": "/mnt/felhom-usb", "intent": "enrolled", "total_bytes": 1000000000000 } ],
"pve_storage": [ { "name": "felhom-usb", "type": "usb", "content": "backup" } ]
}`
asm, err := AssembleDRRecipe(&DRRecipe{CustomerID: "c", RecipeVersion: 1, HostHalfJSON: v1Host})
if err != nil {
t.Fatalf("v1 host half failed to assemble: %v", err)
}
// pbs must survive the stitch.
if !jsonContains(t, asm.PBS, "felhom-pbs") || !jsonContains(t, asm.PBS, "9201") {
t.Errorf("pbs coord not carried through assembly: %s", asm.PBS)
}
// drives passed through verbatim, and carry NEITHER dropped field.
if !jsonContains(t, asm.Drives, "uuid:da9e7089") {
t.Errorf("drives not passed through: %s", asm.Drives)
}
if strings.Contains(string(asm.Drives), "role") || strings.Contains(string(asm.Drives), "restic_repo_coord") {
t.Errorf("v1 drives must not carry role/restic_repo_coord: %s", asm.Drives)
}
}
// TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew: a half carrying an UNKNOWN top-level field and a
// HIGHER recipe_version still assembles (forward-compat), and recipe_version reflects the max.
func TestAssembleDRRecipe_IgnoreUnknownAndVersionSkew(t *testing.T) {
futureHost := `{ "recipe_version": 2, "drives": [], "pve_storage": [], "guests": [],
"future_section": { "whatever": 1 }, "network_topology": ["a","b"] }`
rec := &DRRecipe{CustomerID: "c", RecipeVersion: 2, HostHalfJSON: futureHost, AppHalfJSON: drAppHalf}
asm, err := AssembleDRRecipe(rec)
if err != nil {
t.Fatalf("ignore-unknown failed to parse a forward-compat half: %v", err)
}
if asm.RecipeVersion != 2 {
t.Errorf("recipe_version=%d, want max(2,1)=2", asm.RecipeVersion)
}
// The unknown sections are dropped (not in AssembledRecipe), but the assembly did not error.
b, _ := json.Marshal(asm)
if string(b) == "" {
t.Fatal("empty assembly")
}
}
// TestAssembleDRRecipe_PartialHalves: only one half present → assemble what we have, no error.
func TestAssembleDRRecipe_PartialHalves(t *testing.T) {
onlyApp, err := AssembleDRRecipe(&DRRecipe{AppHalfJSON: drAppHalf})
if err != nil || onlyApp.Apps == nil || onlyApp.Drives != nil {
t.Errorf("only-app assembly wrong: %+v err=%v", onlyApp, err)
}
onlyHost, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf})
if err != nil || onlyHost.Drives == nil || onlyHost.Customer != nil {
t.Errorf("only-host assembly wrong: %+v err=%v", onlyHost, err)
}
empty, err := AssembleDRRecipe(nil)
if err != nil || empty.RecipeVersion != 1 {
t.Errorf("nil assembly should be a v1 empty recipe, got %+v err=%v", empty, err)
}
}
// TestAssembleDRRecipe_NoSecrets: defense-in-depth — the assembled output carries no credential-shaped
// key. (The load-bearing boundary is enforced at the controller emitter; this guards the hub side.)
func TestAssembleDRRecipe_NoSecrets(t *testing.T) {
asm, _ := AssembleDRRecipe(&DRRecipe{HostHalfJSON: drHostHalf, AppHalfJSON: drAppHalf})
b, _ := json.Marshal(asm)
re := regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`)
var v any
json.Unmarshal(b, &v)
var walk func(any)
walk = func(n any) {
switch x := n.(type) {
case map[string]any:
for k, c := range x {
if re.MatchString(k) {
t.Errorf("secret-shaped key %q in assembled recipe", k)
}
walk(c)
}
case []any:
for _, c := range x {
walk(c)
}
}
}
walk(v)
}
// TestAssembleDRRecipe_CarriesEveryEmittedSection is the guard the allow-list needed and never had.
//
// It asserts the CONSEQUENCE — what an operator downloading the recipe actually receives — against the
// halves production really stores, not hand-written structs. Every section either emitter produces must
// survive to the assembled output. When the hostHalfShape/appHalfShape allow-lists and the emitters drift
// apart, this fails; that drift is what dropped `offsite_restic` for the entire life of the feature
// (R-122) and would have dropped `backup_target` on the day R-109 shipped.
func TestAssembleDRRecipe_CarriesEveryEmittedSection(t *testing.T) {
asm, err := AssembleDRRecipe(&DRRecipe{
CustomerID: "demo-felhom", RecipeVersion: 1,
HostHalfJSON: capturedHostHalf, AppHalfJSON: capturedAppHalf,
})
if err != nil {
t.Fatalf("captured real halves failed to assemble: %v", err)
}
b, _ := json.Marshal(asm)
var got map[string]any
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
// Every section BOTH emitters produce today. A new section added to either half without a row here
// (and in the shape struct) is the defect this test exists to catch.
for _, section := range []string{
"recipe_version", "customer", "guests", "pbs", "drives", "pve_storage", "backup_target",
"apps", "offsite_restic",
} {
if _, ok := got[section]; !ok {
t.Errorf("section %q was DROPPED by the assembly — the operator's recipe does not contain it "+
"(add it to hostHalfShape/appHalfShape AND AssembledRecipe)", section)
}
}
// And the values, not just the keys: a present-but-empty section is the same gap wearing a hat.
if !jsonContains(t, asm.BackupTarget, "felhom-backup") || !jsonContains(t, asm.BackupTarget, "/mnt/hdd_1") {
t.Errorf("backup_target reached the operator without naming the live target: %s", asm.BackupTarget)
}
if !jsonContains(t, asm.OffsiteRestic, "u629488-sub1.your-storagebox.de") {
t.Errorf("offsite_restic reached the operator without the repo host: %s", asm.OffsiteRestic)
}
if !jsonContains(t, asm.PBS, "demo-felhom") {
t.Errorf("pbs coord lost the per-customer namespace: %s", asm.PBS)
}
}
// TestAssembleDRRecipe_NamesTheLiveBackupTargetAmongTwoCandidates is the R-109 consequence at the DELIVERY
// boundary: the recipe an operator downloads for a box with two content=backup storages must name the live
// one unambiguously. Assembly-level, because a correct host half that the hub drops helps nobody.
func TestAssembleDRRecipe_NamesTheLiveBackupTargetAmongTwoCandidates(t *testing.T) {
asm, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: capturedHostHalf, AppHalfJSON: capturedAppHalf})
if err != nil {
t.Fatal(err)
}
// The fixture must still pose the problem: two plausible content=backup storages.
var storages []struct{ Name, Type, Content string }
if err := json.Unmarshal(asm.PVEStorage, &storages); err != nil {
t.Fatal(err)
}
candidates := 0
for _, s := range storages {
if strings.Contains(s.Content, "backup") && (s.Type == "local-dir" || s.Type == "local") {
candidates++
}
}
if candidates < 2 {
t.Fatalf("fixture no longer poses the R-109 problem: %d content=backup dir storages", candidates)
}
var bt struct {
State string `json:"state"`
StorageID string `json:"storage_id"`
MountPath string `json:"mount_path"`
}
if len(asm.BackupTarget) == 0 {
t.Fatal("backup_target absent from the delivered recipe — the restorer must still guess (R-109)")
}
if err := json.Unmarshal(asm.BackupTarget, &bt); err != nil {
t.Fatal(err)
}
if bt.State != "resolved" || bt.StorageID != "felhom-backup" || bt.MountPath != "/mnt/hdd_1" {
t.Errorf("delivered backup_target = %+v, want resolved/felhom-backup at /mnt/hdd_1", bt)
}
if bt.StorageID == "local" || bt.MountPath == "/var/lib/vz" {
t.Errorf("delivered recipe names the FROZEN target %q at %q", bt.StorageID, bt.MountPath)
}
}
// TestAssembleDRRecipe_UnknownBackupTargetSurvivesVerbatim: the agent's explicit unknown must reach the
// operator AS an unknown. Passing it through as a resolved-looking section, or dropping it so the recipe
// merely lacks the field, would both turn "I could not tell" into something else.
func TestAssembleDRRecipe_UnknownBackupTargetSurvivesVerbatim(t *testing.T) {
const unknownHost = `{
"recipe_version": 1, "guests": [], "drives": [], "pve_storage": [],
"backup_target": { "state": "unknown", "reason": "agent_backup_config_unavailable" }
}`
asm, err := AssembleDRRecipe(&DRRecipe{HostHalfJSON: unknownHost})
if err != nil {
t.Fatal(err)
}
if !jsonContains(t, asm.BackupTarget, "unknown") ||
!jsonContains(t, asm.BackupTarget, "agent_backup_config_unavailable") {
t.Errorf("the unknown state did not survive assembly: %s", asm.BackupTarget)
}
// It must not have acquired a target on the way through.
if strings.Contains(string(asm.BackupTarget), "storage_id") {
t.Errorf("assembly invented a storage_id for an unknown target: %s", asm.BackupTarget)
}
}
func jsonContains(t *testing.T, raw json.RawMessage, substr string) bool {
t.Helper()
return len(raw) > 0 && string(raw) != "null" && strings.Contains(string(raw), substr)
}
func keysOf(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}