package report import ( "encoding/json" "regexp" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // secretNameRe matches any JSON key that smells like a credential — mirrors the agent half. var secretNameRe = regexp.MustCompile(`(?i)(password|secret|token|hash|passphrase|api[_-]?key|\bkey\b|enc:)`) // rommCompose is a realistic catalog compose: user-data binds via ${USERDATA_PATH}/${HDD_PATH}, plus a // secret-laden environment section (which must NEVER reach the recipe — bindings come from volumes only). const rommCompose = `services: romm: image: romm:latest environment: - DB_PASSWORD=${DB_PASSWORD} - IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET} volumes: - ${USERDATA_PATH}/roms:/roms - ${HDD_PATH}/appdata/romm/resources:/romm/resources - romm_redis_data:/data volumes: romm_redis_data: ` // secretLadenStack builds a romm Stack whose persisted Env carries synthetic secrets (an ENC: value and // a token-shaped value) — exactly what the recipe must keep out. func secretLadenStack() stacks.Stack { return stacks.Stack{ Name: "romm", Meta: stacks.Metadata{Slug: "romm", DisplayName: "RomM"}, ComposePath: "/stacks/romm/docker-compose.yml", Deployed: true, AppConfig: &stacks.AppConfig{ Deployed: true, Env: map[string]string{ "HDD_PATH": "/mnt/felhom-drives/felhom-flash", "DB_PASSWORD": "ENC:U2FsdGVkX1+DEADBEEFsecret==", "IGDB_CLIENT_SECRET": "tok_live_SUPERSECRET_must_not_leak", "SECRET_KEY": "ENC:another_encrypted_blob", }, }, } } // TestBuildAppRecipe_NoSecrets is THE load-bearing boundary test: the emitted AppRecipe for an app whose // deploy config contains secrets (ENC: + token-shaped) carries NONE of those values and NO // credential-shaped key — only the {catalog_ref, enabled, storage_bindings} allowlist. func TestBuildAppRecipe_NoSecrets(t *testing.T) { s := secretLadenStack() rec := buildAppRecipe(s, rommCompose) b, err := json.Marshal(rec) if err != nil { t.Fatal(err) } out := string(b) // (1) none of the secret VALUES survived. for _, leak := range []string{"ENC:", "U2FsdGVkX1", "DEADBEEF", "tok_live_SUPERSECRET_must_not_leak", "another_encrypted_blob"} { if strings.Contains(out, leak) { t.Errorf("SECRET LEAK: emitted recipe contains %q\n recipe: %s", leak, out) } } // (2) no credential-shaped KEY survived (DB_PASSWORD / SECRET_KEY / IGDB_CLIENT_SECRET as keys). assertNoSecretKeys(t, b) // (3) positive: we DID emit the allowlisted facts (not a vacuous pass). if rec.CatalogRef != "romm" || !rec.Enabled { t.Errorf("expected catalog_ref=romm enabled=true, got %+v", rec) } if len(rec.StorageBindings) != 2 { t.Fatalf("expected 2 storage bindings (roms + resources), got %+v", rec.StorageBindings) } } // TestBuildAppRecipe_AllowlistIsLoadBearing is the companion (red-proof of the boundary test): a NAIVE // emitter that dumps AppConfig.Env (i.e. the allowlist guard removed) WOULD leak the token — proving the // no-secrets assertion above is real, not vacuous. The production emitter must NOT leak it. func TestBuildAppRecipe_AllowlistIsLoadBearing(t *testing.T) { s := secretLadenStack() const token = "tok_live_SUPERSECRET_must_not_leak" // The "guard removed" shape — dumping Env alongside the app. This is what the boundary forbids. unsafe, _ := json.Marshal(map[string]any{"catalog_ref": s.Meta.Slug, "env": s.AppConfig.Env}) if !strings.Contains(string(unsafe), token) { t.Fatal("companion is broken: the unsafe (guard-removed) shape should contain the secret token") } // The REAL emitter must keep it out — same fixture, allowlist intact. real, _ := json.Marshal(buildAppRecipe(s, rommCompose)) if strings.Contains(string(real), token) { t.Fatalf("BOUNDARY VIOLATION: production emitter leaked the token: %s", real) } } // Scenario E (fork-4) — the OffsiteRestic DR coord carries coordinates ONLY (no password/key), clears the // secret-name regex, and its fields are emitted. Extends the _NoSecrets boundary to the new field. func TestDRResticCoord_NoSecrets(t *testing.T) { half := &DRRecipeAppHalf{ RecipeVersion: DRRecipeVersion, Customer: DRCustomer{ID: "cust", Display: "Cust", Domain: "demo-felhom.eu"}, Apps: []AppRecipe{}, OffsiteRestic: &DRResticCoord{ Host: "u629193-sub1.your-storagebox.de", User: "u629193-sub1", Port: 23, RepoPath: "/home/felhom-demo-repo", }, } b, err := json.Marshal(half) if err != nil { t.Fatal(err) } // (1) no credential-shaped KEY name survived (the DRResticCoord field names must clear the regex). assertNoSecretKeys(t, b) out := string(b) // (2) positive: the coordinate fields ARE emitted (not a vacuous pass). for _, want := range []string{`"offsite_restic"`, `"host":"u629193-sub1.your-storagebox.de"`, `"user":"u629193-sub1"`, `"port":23`, `"repo_path":"/home/felhom-demo-repo"`} { if !strings.Contains(out, want) { t.Errorf("DR coord missing %s in %s", want, out) } } // (3) the password/key field names must never appear. if strings.Contains(out, "repo_password") || strings.Contains(out, "ssh_key") || strings.Contains(out, "password") { t.Fatalf("DR coord must carry NO password/key: %s", out) } } func TestAppStorageBindings(t *testing.T) { got := appStorageBindings(rommCompose, "/mnt/felhom-drives/felhom-flash") want := map[string]StorageBinding{ "/roms": {ContainerPath: "/roms", Drive: "felhom-flash", Subpath: "userdata/roms"}, "/romm/resources": {ContainerPath: "/romm/resources", Drive: "felhom-flash", Subpath: "appdata/romm/resources"}, } if len(got) != len(want) { t.Fatalf("got %d bindings, want %d: %+v", len(got), len(want), got) } for _, b := range got { w, ok := want[b.ContainerPath] if !ok || b != w { t.Errorf("binding %+v unexpected (want %+v)", b, w) } } // The named volume (romm_redis_data) is NOT a drive bind → excluded. for _, b := range got { if strings.Contains(b.Subpath, "redis") { t.Errorf("named volume leaked into bindings: %+v", b) } } } // TestAppStorageBindings_NoHDD: an app with no HDD_PATH (rootfs-only) yields no bindings, non-nil slice. func TestAppStorageBindings_NoHDD(t *testing.T) { got := appStorageBindings(rommCompose, "") if got == nil || len(got) != 0 { t.Errorf("no HDD_PATH should yield empty (non-nil) bindings, got %+v", got) } } func TestBuildDRRecipeAppHalf(t *testing.T) { reader := func(path string) string { if path == "/stacks/romm/docker-compose.yml" { return rommCompose } return "" } all := []stacks.Stack{ secretLadenStack(), {Name: "traefik", Protected: true, Deployed: true}, // protected → excluded {Name: "vikunja", Meta: stacks.Metadata{Slug: "vikunja"}, Deployed: false}, // not deployed → excluded {Name: "actualbudget", Meta: stacks.Metadata{Slug: "actualbudget"}, Deployed: true}, // rootfs app, no compose path } half := BuildDRRecipeAppHalf("cust-demo", "Demo Customer", "demo-felhom.eu", all, reader) if half.RecipeVersion != 1 { t.Errorf("recipe_version=%d want 1", half.RecipeVersion) } if half.Customer.ID != "cust-demo" || half.Customer.Display != "Demo Customer" || half.Customer.Domain != "demo-felhom.eu" { t.Errorf("customer = %+v", half.Customer) } // Only romm + actualbudget (deployed, non-protected). traefik (protected) + vikunja (not deployed) out. if len(half.Apps) != 2 { t.Fatalf("expected 2 apps, got %d: %+v", len(half.Apps), half.Apps) } byRef := map[string]AppRecipe{} for _, a := range half.Apps { byRef[a.CatalogRef] = a } if r, ok := byRef["romm"]; !ok || len(r.StorageBindings) != 2 { t.Errorf("romm recipe wrong: %+v", r) } if r, ok := byRef["actualbudget"]; !ok || len(r.StorageBindings) != 0 { t.Errorf("actualbudget (rootfs) should have 0 bindings: %+v", r) } // Whole-half no-secrets sweep. b, _ := json.Marshal(half) assertNoSecretKeys(t, b) if strings.Contains(string(b), "tok_live_SUPERSECRET_must_not_leak") { t.Errorf("SECRET LEAK in assembled app-half: %s", b) } } // assertNoSecretKeys walks decoded JSON and fails on any object key matching secretNameRe. 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 _, child := range n { walk(prefix, child) } } } walk("", v) }