package api import ( "go/ast" "go/parser" "go/token" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // R-166 Part 1.3 — THE CUSTOMER-INTENT POINT. // // `stackMgr` is a concrete *stacks.Manager, so actionStack cannot be driven with a fake without // Docker. The two properties that actually carry the correctness are therefore pinned the only way // they can be: the mapping is a pure function with its own table test, and the ORDER (§8.2) is // asserted structurally over actionStack's AST. Both fail if someone reverses the write and the act, // which is the mistake that would undo a customer's Stop at the next boot. func TestDesiredStateForAction_MapsEveryAction(t *testing.T) { cases := []struct { action string want string ok bool }{ {"start", stacks.DesiredStateRunning, true}, // restart and update both END in `compose up -d`, so a customer who presses either is asking // for the app to be up afterwards. {"restart", stacks.DesiredStateRunning, true}, {"update", stacks.DesiredStateRunning, true}, {"stop", stacks.DesiredStateStopped, true}, // Anything unrecognised records NOTHING rather than guessing — a future action must not // silently acquire an intent it was never meant to carry. {"", "", false}, {"delete", "", false}, {"pause", "", false}, } for _, tc := range cases { got, ok := desiredStateForAction(tc.action) if got != tc.want || ok != tc.ok { t.Fatalf("desiredStateForAction(%q) = (%q, %v), want (%q, %v)", tc.action, got, ok, tc.want, tc.ok) } } } func TestDesiredStateForAction_NeverRecordsStoppedForANonStop(t *testing.T) { // The asymmetry that matters: writing "stopped" for anything other than a Stop would permanently // disable auto-recovery for an app nobody stopped. for _, a := range []string{"start", "restart", "update", "deploy", "delete", ""} { if got, _ := desiredStateForAction(a); got == stacks.DesiredStateStopped { t.Fatalf("action %q maps to desired_state=stopped", a) } } } // TestActionStack_RecordsIntentBeforeActing is §8.2, asserted structurally. // // If the SetDesiredState call moved BELOW the action switch, a stop could remove every container // while app.yaml still recorded `running` — and the boot reconciler would then start an app the // customer had just deliberately stopped. That is the single worst outcome available in Part 1, and // no behavioural test in this package can reach it without a Docker daemon. func TestActionStack_RecordsIntentBeforeActing(t *testing.T) { body := funcBody(t, "actionStack") setPos, switchPos := -1, -1 ast.Inspect(body, func(n ast.Node) bool { switch node := n.(type) { case *ast.CallExpr: if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetDesiredState" && setPos < 0 { setPos = int(node.Pos()) } case *ast.SwitchStmt: // The action switch is the one whose tag is the `action` identifier. if id, ok := node.Tag.(*ast.Ident); ok && id.Name == "action" && switchPos < 0 { switchPos = int(node.Pos()) } } return true }) if setPos < 0 { t.Fatal("actionStack no longer calls SetDesiredState — the customer's start/stop decision is " + "recorded nowhere, which is the R-166 defect un-fixed") } if switchPos < 0 { t.Fatal("actionStack no longer has a `switch action` — this test needs updating") } if setPos >= switchPos { t.Fatal("actionStack records the desired state AFTER performing the action (§8.2 violated): a " + "stop whose intent write fails or lands late leaves zero containers with `running` " + "recorded, and the boot reconciler would restart an app the customer just stopped") } } // TestActionStack_RefusesTheActionWhenIntentCannotBeRecorded pins the other half of §8.2: a failed // write REFUSES the act. Proceeding anyway would perform a stop that nothing records — exactly the // ambiguity this release removes. func TestActionStack_RefusesTheActionWhenIntentCannotBeRecorded(t *testing.T) { body := funcBody(t, "actionStack") refuses := false ast.Inspect(body, func(n ast.Node) bool { ifst, ok := n.(*ast.IfStmt) if !ok || ifst.Init == nil { return true } // Look for `if derr := ...SetDesiredState(...); derr != nil { ... return }` assign, ok := ifst.Init.(*ast.AssignStmt) if !ok || len(assign.Rhs) != 1 { return true } call, ok := assign.Rhs[0].(*ast.CallExpr) if !ok { return true } sel, ok := call.Fun.(*ast.SelectorExpr) if !ok || sel.Sel.Name != "SetDesiredState" { return true } for _, stmt := range ifst.Body.List { if _, isReturn := stmt.(*ast.ReturnStmt); isReturn { refuses = true } } return true }) if !refuses { t.Fatal("actionStack does not RETURN when SetDesiredState fails — it would go on to stop or " + "start an app whose intent could not be recorded (§8.2)") } } // funcBody parses router.go and returns the named method's body. func funcBody(t *testing.T, name string) *ast.BlockStmt { t.Helper() fset := token.NewFileSet() f, err := parser.ParseFile(fset, "router.go", nil, 0) if err != nil { t.Fatalf("parse router.go: %v", err) } for _, decl := range f.Decls { if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name && fn.Body != nil { return fn.Body } } t.Fatalf("func %s not found in router.go", name) return nil }