package api import ( "encoding/json" "log" "net/http" "os" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/osupdates" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // The os_update block a box receives is a contract DUPLICATED with felhom-agent: // testdata/desired-state-osupdate.golden.json MUST stay byte-identical with the agent's // internal/hub/testdata copy (the agent's test decodes it). This test proves the hub SERVES exactly that shape. func TestOSUpdate_DesiredBlockMatchesTheGolden(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") svc := &osupdates.Service{Store: st, ApproveAfter: 0, NightsRequired: 0, Logger: log.New(os.Stderr, "", 0)} h.SetOSUpdateService(svc) rel := `[{"name":"libc6","version":"2.41-12+deb13u4","origin":"Debian"},{"name":"openssl","version":"3.5.7-1~deb13u3","origin":"Debian-Security"}]` if err := st.SaveOSRelease(storeRelease("os-guest-20261004-120000", "guest", rel)); err != nil { t.Fatal(err) } hrel := `[{"name":"libssl3t64","version":"3.5.7-1~deb13u3","origin":"Debian-Security"}]` if err := st.SaveOSRelease(storeRelease("os-host-20261004-120000", "host", hrel)); err != nil { t.Fatal(err) } rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "") if rr.Code != 200 { t.Fatalf("GET desired-state: %d", rr.Code) } var got struct { DesiredState struct { OSUpdate json.RawMessage `json:"os_update"` } `json:"desired_state"` } json.Unmarshal(rr.Body.Bytes(), &got) raw, err := os.ReadFile("testdata/desired-state-osupdate.golden.json") if err != nil { t.Fatal(err) } var golden struct { DesiredState struct { OSUpdate json.RawMessage `json:"os_update"` } `json:"desired_state"` } if err := json.Unmarshal(raw, &golden); err != nil { t.Fatal(err) } var a, b any json.Unmarshal(got.DesiredState.OSUpdate, &a) json.Unmarshal(golden.DesiredState.OSUpdate, &b) ab, _ := json.Marshal(a) bb, _ := json.Marshal(b) if string(ab) != string(bb) { t.Fatalf("served os_update diverged from the golden:\n served: %s\n golden: %s", ab, bb) } } // A box reports only for itself; another box's key is refused and nothing is stored. func TestOSReport_SelfScoped(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") seedHost(t, st, "h2", "c2", "HKEY2") h.SetOSUpdateService(&osupdates.Service{Store: st}) body := `{"run_id":"r1","trigger":"night","mode":"apply","outcome":"applied","healthy":true,"upgraded":[{"name":"libc6","version":"x","origin":"Debian"}]}` if rr := do(h, http.MethodPost, "/hosts/h1/os-report", "HKEY2", body); rr.Code != http.StatusForbidden { t.Fatalf("cross-host report → %d, want 403", rr.Code) } if r, _ := st.LatestOSReport("h1", "guest"); r != nil { t.Fatal("a refused report was stored") } if rr := do(h, http.MethodPost, "/hosts/h1/os-report", "HKEY1", body); rr.Code != http.StatusOK { t.Fatalf("own report → %d %s", rr.Code, rr.Body.String()) } if r, _ := st.LatestOSReport("h1", "guest"); r == nil || r.Outcome != "applied" { t.Fatalf("report not stored: %+v", r) } } func storeRelease(id, layer, pkgs string) store.OSRelease { at, _ := time.Parse(time.RFC3339, "2026-10-04T12:00:00Z") return store.OSRelease{ID: id, Layer: layer, Fingerprint: "fp-" + layer, ApprovedAt: at, ApprovedBy: "auto", PackagesJSON: pkgs} }