gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
170 lines
8.1 KiB
Go
170 lines
8.1 KiB
Go
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 ask (an UNTYPED nil — the report omits the field); after a refresh,
|
|
// everything; after an agent RESTART, the kernel and time of that refresh again — from the wrapper's answer to the new
|
|
// process's ask, because the wrapper's record FILE may be root-only on a box (its folder is 0700 where a signed job
|
|
// made it first). Where the file can be read, it adds the detail back.
|
|
// 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})
|
|
unreadable := filepath.Join(t.TempDir(), "root-only", "profile-refresh.json") // does not exist: as a 0700 folder reads
|
|
l.ProfileRecordPath = unreadable
|
|
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 ask the stanza is %#v, want an untyped nil", st)
|
|
}
|
|
if got := l.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "refreshed" {
|
|
t.Fatalf("outcome = %q", got)
|
|
}
|
|
b, _ := json.Marshal(l.ProfileRefreshStanza())
|
|
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","rewritten":1,"restarted":["chrony.service"],"last_ask":"refreshed","last_ask_at":"2026-11-14T11:30:11Z"}` {
|
|
t.Fatalf("stanza after a refresh, record file unreadable = %s", b)
|
|
}
|
|
// an agent restart: a new process asks, the wrapper answers "not needed" with ITS record of the last refresh
|
|
l2, _ := newLeg(t, &fakeWrapper{t: t, profileRep: &WrapperReport{ProfileRefresh: json.RawMessage(
|
|
`{"outcome":"not_needed","reason":"this kernel was done","kernel":"7.0.14-28-pve","last_refresh_kernel":"7.0.14-28-pve","last_refresh_at":"2026-11-14T11:30:11Z"}`)}},
|
|
&hub.WireOSUpdate{Ring: 1, Enabled: true})
|
|
l2.ProfileRecordPath = unreadable
|
|
l2.Now = func() time.Time { return now.Add(3 * time.Hour) }
|
|
if st := l2.ProfileRefreshStanza(); st != nil {
|
|
t.Fatalf("a new process that has not asked and cannot read the record: %#v, want nil", st)
|
|
}
|
|
if got := l2.ProfileRefresh(context.Background(), "agent start", nil, 1, time.Second); got != "not_needed" {
|
|
t.Fatalf("outcome = %q", got)
|
|
}
|
|
b, _ = json.Marshal(l2.ProfileRefreshStanza())
|
|
if string(b) != `{"kernel":"7.0.14-28-pve","at":"2026-11-14T11:30:11Z","last_ask":"not_needed","last_ask_at":"2026-11-14T14:30:11Z"}` {
|
|
t.Fatalf("stanza after an agent restart (the wrapper's answer, no detail) = %s", b)
|
|
}
|
|
// where the record file CAN be read, the detail is back — and a name that is not a unit name is dropped
|
|
rec := filepath.Join(t.TempDir(), "profile-refresh.json")
|
|
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":["a","b"]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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"],"last_ask":"not_needed","last_ask_at":"2026-11-14T14:30:11Z"}` {
|
|
t.Fatalf("stanza with a readable record = %s", b)
|
|
}
|
|
// a failed ask is told, beside the older record
|
|
l2.Runner = (&fakeWrapper{t: t, profileRep: &WrapperReport{Failed: json.RawMessage(`{"code":"F1","reason":"apparmor_parser failed"}`)}})
|
|
l2.Now = func() time.Time { return now.Add(4 * time.Hour) }
|
|
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-14T15: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 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)
|
|
}
|
|
}
|