catalog: app lifecycle states - available/hidden/abandoned (v0.158.0)
The catalog knew only 'present' or 'gone', and 'gone' orphans every customer already running the app. lifecycle: in .felhom.yml withdraws an app from new installs without touching anyone running it. Deploy gate is server-side and fail-closed, before any mutation, with the ruled Hungarian refusal - hiding a button is not a gate. Unknown values fail OPEN (available + one WARN), deliberately opposite, so a typo or a newer catalog cannot pull a working app out of every customer's list. Orphan detection never sees the field - a red-proof adds that filter and shows the abandoned app immediately reading as an orphan. Badge plumbing is generic (MetaBadge + meta_badge partial) so R-56's difficulty labels drop in with no new markup.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDeployStackWiresTheLifecycleGate — the seam-discipline test (§9 rule 6).
|
||||
//
|
||||
// The lifecycle predicate is unit-tested in internal/stacks, and a test there passes whether or not
|
||||
// deployStack ever calls it. Three inert-seam defects shipped fully-green in three days (controller
|
||||
// v0.154.0, agent v0.91.0, agent v0.92.0's missing sudoers grant), all this exact shape: correct
|
||||
// component, absent caller. So the CALLER is asserted here, from source.
|
||||
//
|
||||
// It walks the AST rather than doing strings.Contains on the file, because a commented-out call
|
||||
// still contains the string — the lesson recorded in PROMPT-TEMPLATE §10.
|
||||
//
|
||||
// It also asserts ORDER: the gate must precede the DeployStack call, or it is not fail-closed.
|
||||
func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0) // comments dropped: a commented call is not a call
|
||||
if err != nil {
|
||||
t.Fatalf("parse router.go: %v", err)
|
||||
}
|
||||
|
||||
var fn *ast.FuncDecl
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "deployStack" {
|
||||
fn = d
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if fn == nil {
|
||||
t.Fatal("deployStack not found in router.go — did it move? the gate's wiring is now unasserted")
|
||||
}
|
||||
|
||||
canInstallPos, deployPos := -1, -1
|
||||
ast.Inspect(fn, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
off := fset.Position(call.Pos()).Offset
|
||||
switch sel.Sel.Name {
|
||||
case "CanInstall":
|
||||
if canInstallPos == -1 {
|
||||
canInstallPos = off
|
||||
}
|
||||
case "DeployStack":
|
||||
if deployPos == -1 {
|
||||
deployPos = off
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if canInstallPos == -1 {
|
||||
t.Fatal("deployStack never calls Meta.CanInstall() — the lifecycle gate is INERT: " +
|
||||
"a withdrawn app is hidden from the catalog page but still installable by direct POST")
|
||||
}
|
||||
if deployPos == -1 {
|
||||
t.Fatal("deployStack no longer calls DeployStack — this test's ordering assertion is meaningless")
|
||||
}
|
||||
if canInstallPos > deployPos {
|
||||
t.Fatalf("the lifecycle gate (offset %d) runs AFTER DeployStack (offset %d) — a gate that "+
|
||||
"fires after the mutation is not fail-closed", canInstallPos, deployPos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
|
||||
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
|
||||
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = "Ez az alkalmazás jelenleg nem telepíthető."
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user