package localapi import ( "encoding/json" "io" "log/slog" "net/http" "os" "path/filepath" "testing" ) // The shape of /var/lib/felhom-crash-guard/state.json as read on demo-hp on 2026-10-06 (values from // that read where they matter; lists/objects kept to the same key set). const crashGuardFixture = `{ "armed": true, "boot_id": "3f1c0f1e-6a0b-4d7e-9b7a-0c2d4e6f8a1b", "config": {"LIMIT": 3, "WINDOW_MINUTES": 60, "PANIC_SECONDS": 10}, "kernel_panic": 10, "last_boot_at": "2026-10-05T07:56:41Z", "last_boot_unclean": true, "last_trip": {}, "rearmed_at": "2026-10-04T14:02:11Z", "rearmed_by": "operator", "tripped": false, "unclean_boots": ["2026-10-05T07:56:41Z"], "unclean_boots_24h": 1, "unclean_boots_in_window": 1, "updated_at": "2026-10-05T07:57:02Z", "version": 1 }` // controllerCrashGuardState is a COPY of the controller's wire type, felhom-controller // controller/internal/agentapi/crashguard.go `CrashGuardState` (commit 8b13a5e) — same field names, // same tags. If either side renames a key, the contract test below fails. type controllerCrashGuardState struct { Present bool `json:"present"` LastBootAt string `json:"last_boot_at,omitempty"` LastBootUnclean bool `json:"last_boot_unclean"` Tripped bool `json:"tripped"` } func newCrashGuardServer(t *testing.T, statePath string) http.Handler { t.Helper() srv, err := NewServer(Options{ ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200, "B": 9300}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("new server: %v", err) } srv.crashGuardStatePath = statePath return srv.Handler() } func writeCrashGuardFixture(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "state.json") if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } return p } // getCrashGuard calls the route and decodes the envelope with the CONTROLLER's type. func getCrashGuard(t *testing.T, h http.Handler, token string) (int, controllerCrashGuardState, string) { t.Helper() w := do(t, h, "GET", "/host/crash-guard", token, "") var env struct { OK bool `json:"ok"` Data controllerCrashGuardState `json:"data"` } if w.Code == http.StatusOK { if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatalf("decode %q: %v", w.Body.String(), err) } if !env.OK { t.Fatalf("ok=false: %s", w.Body.String()) } } return w.Code, env.Data, w.Body.String() } // A present state file (demo-hp's shape) passes the three facts through. func TestR856_CrashGuardPresentFile(t *testing.T) { h := newCrashGuardServer(t, writeCrashGuardFixture(t, crashGuardFixture)) code, st, body := getCrashGuard(t, h, "A") if code != http.StatusOK { t.Fatalf("got %d, want 200 (%s)", code, body) } want := controllerCrashGuardState{Present: true, LastBootAt: "2026-10-05T07:56:41Z", LastBootUnclean: true, Tripped: false} if st != want { t.Fatalf("state = %+v, want %+v", st, want) } // A tripped, clean boot reads back as such (both bools are carried, not defaulted). h = newCrashGuardServer(t, writeCrashGuardFixture(t, `{"last_boot_at":"2026-10-05T09:56:41+02:00","last_boot_unclean":false,"tripped":true,"version":1}`)) _, st, _ = getCrashGuard(t, h, "B") want = controllerCrashGuardState{Present: true, LastBootAt: "2026-10-05T07:56:41Z", LastBootUnclean: false, Tripped: true} if st != want { t.Fatalf("offset time / tripped: state = %+v, want %+v (time normalised to UTC Z)", st, want) } } // No state file (no guard on this host, or no boot recorded yet) → 200 present:false. func TestR856_CrashGuardMissingFile(t *testing.T) { h := newCrashGuardServer(t, filepath.Join(t.TempDir(), "absent", "state.json")) code, st, body := getCrashGuard(t, h, "A") if code != http.StatusOK { t.Fatalf("missing file: got %d, want 200 (%s)", code, body) } if st.Present || st.LastBootUnclean || st.Tripped || st.LastBootAt != "" { t.Fatalf("missing file: state = %+v, want present:false and nothing else", st) } } // A garbled file → 200 present:false, never a 5xx — every shape of garbage. func TestR856_CrashGuardGarbageFile(t *testing.T) { for name, body := range map[string]string{ "truncated": crashGuardFixture[:40], "not json": "this is not json\n", "empty": "", "null": "null", "array": `[{"last_boot_unclean":true}]`, "wrong type": `{"last_boot_at":"2026-10-05T07:56:41Z","last_boot_unclean":"yes","tripped":false}`, "lone brace": "{", } { t.Run(name, func(t *testing.T) { h := newCrashGuardServer(t, writeCrashGuardFixture(t, body)) code, st, raw := getCrashGuard(t, h, "A") if code != http.StatusOK { t.Fatalf("got %d, want 200 (%s)", code, raw) } if st.Present || st.LastBootUnclean { t.Fatalf("garbage %q read as %+v, want present:false", name, st) } }) } // The path is a directory, not a file: unreadable → present:false, 200. h := newCrashGuardServer(t, t.TempDir()) if code, st, raw := getCrashGuard(t, h, "A"); code != http.StatusOK || st.Present { t.Fatalf("directory path: got %d %+v (%s), want 200 present:false", code, st, raw) } } // No / unknown token → 401, like every sibling route; a cross-guest ?vmid= → 403. func TestR856_CrashGuardRequiresGuestToken(t *testing.T) { h := newCrashGuardServer(t, writeCrashGuardFixture(t, crashGuardFixture)) for _, tok := range []string{"", "bogus"} { w := do(t, h, "GET", "/host/crash-guard", tok, "") if w.Code != http.StatusUnauthorized { t.Fatalf("token %q: got %d, want 401", tok, w.Code) } if json.Valid(w.Body.Bytes()) { var env struct { Data controllerCrashGuardState `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &env) if env.Data.Present || env.Data.LastBootUnclean { t.Fatalf("token %q: the refusal leaked the state: %s", tok, w.Body.String()) } } } if w := do(t, h, "GET", "/host/crash-guard?vmid=9300", "A", ""); w.Code != http.StatusForbidden { t.Fatalf("cross-guest query: got %d, want 403", w.Code) } } // Wire contract: every key the controller's CrashGuardState decodes is emitted under exactly that // name, and the agent emits no key the controller does not know. func TestR856_CrashGuardWireMatchesControllerClient(t *testing.T) { h := newCrashGuardServer(t, writeCrashGuardFixture(t, crashGuardFixture)) w := do(t, h, "GET", "/host/crash-guard", "A", "") var env struct { OK bool `json:"ok"` Data map[string]json.RawMessage `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil || !env.OK { t.Fatalf("envelope: %v %s", err, w.Body.String()) } want := []string{"present", "last_boot_at", "last_boot_unclean", "tripped"} for _, k := range want { if _, ok := env.Data[k]; !ok { t.Errorf("agent does not emit %q, which the controller decodes (%s)", k, w.Body.String()) } } if len(env.Data) != len(want) { t.Errorf("agent emits %d keys, controller knows %d: %s", len(env.Data), len(want), w.Body.String()) } // And the agent's own type agrees with the controller's copy, field for field. var mine CrashGuardResponse var theirs controllerCrashGuardState raw, _ := json.Marshal(env.Data) _ = json.Unmarshal(raw, &mine) _ = json.Unmarshal(raw, &theirs) if (controllerCrashGuardState{mine.Present, mine.LastBootAt, mine.LastBootUnclean, mine.Tripped}) != theirs { t.Errorf("agent %+v vs controller %+v", mine, theirs) } }