Files
felhom-controller/controller/internal/report/dr_recipe_test.go
T
admin f3146ac7bf controller v0.73.0: DR recipe — emit secret-free customer+apps half in hub report
DR recipe slice (controller half), grounded in SPIKE-dr-recipe-2026-06-16. The
controller emitter is the BOUNDARY enforcement point: v1 ships an explicit
allowlist {catalog_ref, enabled, storage_bindings} and reads NOTHING from
AppConfig.Env, so no ENC:/token/password can leak. storage_bindings parsed from
the compose (${HDD_PATH}/${USERDATA_PATH} volume binds -> {container_path,
drive, subpath}).

Load-bearing tests: TestBuildAppRecipe_NoSecrets (synthetic-secret app -> none
leak) + TestBuildAppRecipe_AllowlistIsLoadBearing (red-proof companion) +
TestAppStorageBindings + TestBuildDRRecipeAppHalf. Red-proofed live: forcing the
emitter to dump Env makes the boundary test fail. recipe_version=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:38:59 +02:00

202 lines
7.3 KiB
Go

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)
}
}
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("<root>", v)
}