package osupdate import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // ---- the security-profile refresh (R-1388, R-1301, `11` §5.12) ---- func profileCalls(w *fakeWrapper) []map[string]any { var m []map[string]any for _, p := range w.plans { if p["mode"] == "profile-refresh" { m = append(m, p) } } return m } func refreshed() *WrapperReport { return &WrapperReport{ProfileRefresh: json.RawMessage(`{"outcome":"refreshed","kernel":"7.0.14-28-pve","rewritten":["f6da0254.0/usr.sbin.chronyd"],` + `"restarted":[{"unit":"chrony.service","program":"/usr/sbin/chronyd","rc":0,"active":true}],"seconds":0.5}`)} } // One call, a host-layer plan that names NOTHING, and nothing to the hub. // COMPANION RED-PROOF (observed): add "units" to the plan → this test fails; drop the Appliance check → the BYO test fails. func TestProfileRefresh_OneCallNoNamesNoHubReport(t *testing.T) { w := &fakeWrapper{t: t, profileRep: refreshed()} l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true}) if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "refreshed" { t.Fatalf("outcome = %q", got) } pc := profileCalls(w) if len(pc) != 1 { t.Fatalf("calls = %d", len(pc)) } if len(pc[0]) != 3 || pc[0]["layer"] != LayerHost || pc[0]["release_id"] != "profile-refresh" { t.Fatalf("the plan carries more than release_id, layer and mode: %v", pc[0]) } if len(h.reports) != 0 { t.Fatalf("the hub got a report: %+v", h.reports) } } func TestProfileRefresh_ByoHostIsNeverAsked(t *testing.T) { w := &fakeWrapper{t: t, profileRep: refreshed()} l, _ := newLeg(t, w, nil) l.Appliance = false if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "" || len(profileCalls(w)) != 0 { t.Fatalf("a BYO host was asked: %q %v", got, profileCalls(w)) } } // The heavy-op gate: busy → wait and ask again; still busy after the tries → not run (the next start asks again). // COMPANION RED-PROOF (observed): ignore `ok` from the gate → the "always busy" half fails. func TestProfileRefresh_WaitsForTheHeavyOpGate(t *testing.T) { w := &fakeWrapper{t: t, profileRep: refreshed()} l, _ := newLeg(t, w, nil) n, released := 0, 0 gate := func() (func(), string, bool) { n++ if n < 3 { return nil, "primary-backup", false } return func() { released++ }, "", true } if got := l.ProfileRefresh(context.Background(), "agent start", gate, 5, time.Minute); got != "refreshed" { t.Fatalf("outcome = %q after %d gate tries", got, n) } if n != 3 || released != 1 || len(profileCalls(w)) != 1 { t.Fatalf("gate tries %d, released %d, calls %d", n, released, len(profileCalls(w))) } w2 := &fakeWrapper{t: t, profileRep: refreshed()} l2, _ := newLeg(t, w2, nil) busy := func() (func(), string, bool) { return nil, "restore-test", false } if got := l2.ProfileRefresh(context.Background(), "agent start", busy, 3, time.Minute); got != "" || len(profileCalls(w2)) != 0 { t.Fatalf("ran beside a heavy operation: %q %d call(s)", got, len(profileCalls(w2))) } } // An older wrapper refuses the mode: logged, nothing else. A failure is said as "failed". func TestProfileRefresh_RefusedAndFailed(t *testing.T) { w := &fakeWrapper{t: t, profileRep: &WrapperReport{Refused: json.RawMessage(`{"code":"R11","reason":"unknown mode 'profile-refresh'"}`)}} l, _ := newLeg(t, w, nil) if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "" { t.Fatalf("refused → %q", got) } w2 := &fakeWrapper{t: t, profileRep: &WrapperReport{Failed: json.RawMessage(`{"rc":1,"step":"profile-refresh"}`)}} l2, _ := newLeg(t, w2, nil) if got := l2.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "failed" { t.Fatalf("failed → %q", got) } } // R-1383 (release 3): the refresh restarted a failed service on the N100 and only the box's journal knew. The stanza // that rides the host report: nothing before any refresh (an UNTYPED nil — the report omits the field); after a // refresh, the root wrapper's own record (so it outlives an agent restart) and how this process's last ask ended. // COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-agent-b3.txt). func TestProfileRefreshStanza_RecordAndLastAsk(t *testing.T) { w := &fakeWrapper{t: t, profileRep: refreshed()} l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true}) rec := filepath.Join(t.TempDir(), "profile-refresh.json") l.ProfileRecordPath = rec now := time.Date(2026, 11, 14, 11, 30, 11, 0, time.UTC) l.Now = func() time.Time { return now } if st := l.ProfileRefreshStanza(); st != nil { t.Fatalf("before any refresh the stanza is %#v, want an untyped nil", st) } // the wrapper refreshes and writes ITS record (the fake wrapper does not, so the test writes what the real one does) if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "refreshed" { t.Fatalf("outcome = %q", got) } if err := os.WriteFile(rec, []byte(`{"at":"2026-11-14T11:30:11Z","kernel":"7.0.14-28-pve","restarted":["chrony.service","bad name; rm -rf"],`+ `"rewritten":["f6da0254.0/usr.sbin.chronyd","f6da0254.0/lxc-containers"]}`), 0o644); err != nil { t.Fatal(err) } b, _ := json.Marshal(l.ProfileRefreshStanza()) if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":2,"restarted":["chrony.service"],"last_ask":"refreshed","last_ask_at":"2026-11-14T11:30:11Z"}` { t.Fatalf("stanza after a refresh = %s", b) } // an agent restart: a new process, the same record — the box's last refresh is still told l2, _ := newLeg(t, &fakeWrapper{t: t}, &hub.WireOSUpdate{Ring: 1, Enabled: true}) l2.ProfileRecordPath = rec b, _ = json.Marshal(l2.ProfileRefreshStanza()) if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":2,"restarted":["chrony.service"]}` { t.Fatalf("stanza after an agent restart = %s", b) } // a failed ask is told, beside the older record l2.Now = func() time.Time { return now.Add(time.Hour) } l2.Runner = (&fakeWrapper{t: t, profileRep: &WrapperReport{Failed: json.RawMessage(`{"code":"F1","reason":"apparmor_parser failed"}`)}}) if got := l2.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "failed" { t.Fatalf("outcome = %q", got) } b, _ = json.Marshal(l2.ProfileRefreshStanza()) if !strings.Contains(string(b), `"last_ask":"failed","last_ask_at":"2026-11-14T12:30:11Z"`) || !strings.Contains(string(b), `"kernel":"7.0.14-28-pve"`) { t.Fatalf("stanza after a failed ask = %s", b) } // a record that is not JSON, or huge, says nothing and breaks nothing _ = os.WriteFile(rec, []byte("not json"), 0o644) l3, _ := newLeg(t, &fakeWrapper{t: t}, &hub.WireOSUpdate{Ring: 1, Enabled: true}) l3.ProfileRecordPath = rec if st := l3.ProfileRefreshStanza(); st != nil { t.Fatalf("an unreadable record gave the stanza %#v", st) } }