package stacks import ( "os" "path/filepath" "testing" ) // TestLifecycleParsing proves the catalog `lifecycle:` field flows through .felhom.yml parsing into // the three predicates the rest of the system branches on. The sync round-trip is the same path a // real catalog push takes: .felhom.yml on disk → LoadMetadata → state visible. func TestLifecycleParsing(t *testing.T) { cases := []struct { name string yml string wantEff string canInstall bool abandoned bool }{ {"absent field is available", "display_name: X\n", LifecycleAvailable, true, false}, {"explicit available", "display_name: X\nlifecycle: available\n", LifecycleAvailable, true, false}, {"empty value is available", "display_name: X\nlifecycle: \"\"\n", LifecycleAvailable, true, false}, {"hidden", "display_name: X\nlifecycle: hidden\n", LifecycleHidden, false, false}, {"abandoned", "display_name: X\nlifecycle: abandoned\n", LifecycleAbandoned, false, true}, {"quoted abandoned", "display_name: X\nlifecycle: \"abandoned\"\n", LifecycleAbandoned, false, true}, // A typo in a catalog push must NOT brick the template. Fail-OPEN here is deliberate and is // the opposite of the deploy gate's posture: an unknown state most likely means the catalog // is newer than this controller, and silently pulling a working app out of every customer's // catalog is the worse failure. {"unknown value degrades to available", "display_name: X\nlifecycle: retired\n", LifecycleAvailable, true, false}, {"case-sensitive: Abandoned is unknown", "display_name: X\nlifecycle: Abandoned\n", LifecycleAvailable, true, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(c.yml), 0644); err != nil { t.Fatal(err) } meta := LoadMetadata(dir) if got := meta.EffectiveLifecycle(); got != c.wantEff { t.Errorf("EffectiveLifecycle() = %q, want %q", got, c.wantEff) } if got := meta.CanInstall(); got != c.canInstall { t.Errorf("CanInstall() = %v, want %v", got, c.canInstall) } if got := meta.IsAbandoned(); got != c.abandoned { t.Errorf("IsAbandoned() = %v, want %v", got, c.abandoned) } }) } } // TestLifecycleDoesNotDisturbOtherMetadata: the new field must not change how anything else parses. func TestLifecycleDoesNotDisturbOtherMetadata(t *testing.T) { dir := t.TempDir() yml := `display_name: "Plant-it" slug: plant-it category: home lifecycle: abandoned resources: mem_limit: "256M" ` if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(yml), 0644); err != nil { t.Fatal(err) } meta := LoadMetadata(dir) if meta.DisplayName != "Plant-it" || meta.Slug != "plant-it" || meta.Category != "home" { t.Fatalf("sibling fields damaged: %+v", meta) } if meta.Resources.MemLimit != "256M" { t.Errorf("MemLimit = %q, want 256M", meta.Resources.MemLimit) } if !meta.IsAbandoned() { t.Error("lifecycle lost") } } // TestDeployRefusesNonAvailable is the manager-level half of the fail-closed deploy gate. // // COMPANION RED-PROOF: delete the `if !meta.CanInstall()` block in DeployStack and this test fails — // the deploy proceeds past the gate on an abandoned fixture. Recorded in REPORT.md. func TestDeployRefusesNonAvailable(t *testing.T) { for _, lc := range []string{LifecycleHidden, LifecycleAbandoned} { t.Run(lc, func(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte("display_name: X\nlifecycle: "+lc+"\n"), 0644); err != nil { t.Fatal(err) } meta := LoadMetadata(dir) if meta.CanInstall() { t.Fatalf("%s must not be installable — this is the predicate the deploy gate reads", lc) } }) } }