Files
felhom.eu/hub/internal/api/host_test.go
T
admin 41f2d2b5da hub v0.7.3: ingest agent backups + restore_tests (slice 6 Phase A)
Accept + persist the now-populated host-report backups/restore_tests. Mirror structs in
hostReportPayload; persisted via report_json (no schema change); a FAILED restore-test is
logged prominently (loudest DR signal). Shared golden updated byte-identical with
felhom-agent; bidirectional key-set tests added. Build/deploy deferred (backward-compatible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:56:18 +02:00

361 lines
13 KiB
Go

package api
import (
"database/sql"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
_ "modernc.org/sqlite"
)
const globalKey = "GLOBALKEY"
func newTestHandler(t *testing.T) (*Handler, *store.Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
h := New(st, globalKey, "", "", nil, log.New(io.Discard, "", 0))
return h, st, path
}
func do(h *Handler, method, path, bearer, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/api/v1"+path, strings.NewReader(body))
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func TestCheckAuthHost(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
mk := func(bearer string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/host-report", nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return req
}
if _, _, isGlobal, ok := h.checkAuthHost(mk(globalKey)); !ok || !isGlobal {
t.Error("global key should resolve isGlobal=true")
}
hostID, custID, isGlobal, ok := h.checkAuthHost(mk("HKEY"))
if !ok || isGlobal || hostID != "h1" || custID != "c1" {
t.Errorf("per-host key = %q/%q global=%v ok=%v", hostID, custID, isGlobal, ok)
}
if _, _, _, ok := h.checkAuthHost(mk("bogus")); ok {
t.Error("unknown key should fail")
}
}
func validReportBody(hostID string) string {
return `{"host_id":"` + hostID + `","agent_version":"0.3.0",` +
`"host":{"cpu_percent":3.2,"memory_percent":25,"disk_percent":19,"loadavg":["0.1"],"uptime_seconds":100},` +
`"guests":[{"vmid":100,"name":"acme","status":"running","controller_version":""},` +
`{"vmid":101,"name":"beta","status":"stopped"}],` +
`"storage_targets":[],"backups":[],"cloudflared":{"status":"active"},"audit_tail":[]}`
}
func TestHandleHostReport_ValidAndEnvelopeAndDenorm(t *testing.T) {
h, st, dbPath := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
if rr.Code != 200 {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
var env struct {
Status string `json:"status"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
Blocked bool `json:"blocked"`
DesiredGeneration int `json:"desired_generation"`
HasSignedOps bool `json:"has_signed_ops"`
}
json.Unmarshal(rr.Body.Bytes(), &env)
if env.Status != "ok" || env.PollIntervalSeconds != 900 || env.Blocked || env.DesiredGeneration != 0 || env.HasSignedOps {
t.Errorf("envelope = %+v", env)
}
// Denorm: guest_running counts only "running" (1 of 2). Read via a 2nd connection.
db, _ := sql.Open("sqlite", dbPath)
defer db.Close()
var total, running int
var cf string
db.QueryRow(`SELECT guest_total, guest_running, cloudflared_status FROM host_reports WHERE host_id='h1' ORDER BY id DESC LIMIT 1`).
Scan(&total, &running, &cf)
if total != 2 || running != 1 || cf != "active" {
t.Errorf("denorm total=%d running=%d cloudflared=%q (want 2,1,active)", total, running, cf)
}
// Guests upserted.
var gname, gstatus string
if err := db.QueryRow(`SELECT display_name, status FROM guests WHERE guest_id='h1/100'`).Scan(&gname, &gstatus); err != nil {
t.Fatalf("guest h1/100 not upserted: %v", err)
}
if gname != "acme" || gstatus != "running" {
t.Errorf("guest = %q/%q", gname, gstatus)
}
}
func TestHandleHostReport_HostIDMismatch(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("other-host"))
if rr.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rr.Code)
}
}
func TestHandleHostReport_UnknownHostUnderGlobalKey(t *testing.T) {
h, _, _ := newTestHandler(t)
rr := do(h, http.MethodPost, "/host-report", globalKey, validReportBody("ghost"))
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (unknown host_id)", rr.Code)
}
}
func TestHandleHostReport_BlockedCustomer(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.SetCustomerConfigStatus("c1", "blocked")
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
if rr.Code != 200 {
t.Fatalf("status = %d", rr.Code)
}
var env struct {
Blocked bool `json:"blocked"`
}
json.Unmarshal(rr.Body.Bytes(), &env)
if !env.Blocked {
t.Error("blocked customer should yield blocked:true")
}
}
func TestHandleHostReport_OversizeRejected(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
big := `{"host_id":"h1","guests":[{"vmid":1,"name":"` + strings.Repeat("a", 5<<20) + `"}]}`
rr := do(h, http.MethodPost, "/host-report", "HKEY", big)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize body status = %d, want 413", rr.Code)
}
}
func TestAdminCreateHost(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
// non-global key (per-customer) → 403
if rr := do(h, http.MethodPost, "/admin/hosts", "ckey", `{"customer_id":"c1"}`); rr.Code != http.StatusForbidden {
t.Errorf("per-customer key status = %d, want 403", rr.Code)
}
// missing/unknown customer → 400
if rr := do(h, http.MethodPost, "/admin/hosts", globalKey, `{"customer_id":"nope"}`); rr.Code != http.StatusBadRequest {
t.Errorf("unknown customer status = %d, want 400", rr.Code)
}
// success → 201 + usable key (round-trip)
rr := do(h, http.MethodPost, "/admin/hosts", globalKey, `{"customer_id":"c1"}`)
if rr.Code != http.StatusCreated {
t.Fatalf("mint status = %d, body=%s", rr.Code, rr.Body.String())
}
var minted struct {
HostID string `json:"host_id"`
APIKey string `json:"api_key"`
}
json.Unmarshal(rr.Body.Bytes(), &minted)
if minted.HostID == "" || minted.APIKey == "" {
t.Fatalf("mint response = %+v", minted)
}
// the minted key authenticates a host-report
rr2 := do(h, http.MethodPost, "/host-report", minted.APIKey, validReportBody(minted.HostID))
if rr2.Code != 200 {
t.Errorf("round-trip host-report with minted key = %d, body=%s", rr2.Code, rr2.Body.String())
}
}
// TestHostReport_GoldenContract drives the real handler with the shared golden
// host-report and proves hostReportPayload still extracts what it needs from the
// real wire shape (denorm + guest upsert).
//
// testdata/host-report.golden.json MUST be kept byte-identical with felhom-agent's
// internal/hub/testdata/host-report.golden.json — it is a duplicated contract until
// a shared types module exists (revisit when slices 5/6 add real fields).
func TestHostReport_GoldenContract(t *testing.T) {
h, st, dbPath := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "demo-host-01", CustomerID: "c1", APIKey: "HKEY"})
golden, err := os.ReadFile("testdata/host-report.golden.json")
if err != nil {
t.Fatal(err)
}
rr := do(h, http.MethodPost, "/host-report", "HKEY", string(golden))
if rr.Code != 200 {
t.Fatalf("golden report status = %d, body=%s", rr.Code, rr.Body.String())
}
db, _ := sql.Open("sqlite", dbPath)
defer db.Close()
var total, running int
var cf string
if err := db.QueryRow(`SELECT guest_total, guest_running, cloudflared_status FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`).
Scan(&total, &running, &cf); err != nil {
t.Fatal(err)
}
if total != 2 || running != 1 || cf != "active" {
t.Errorf("denorm total=%d running=%d cloudflared=%q (want 2,1,active)", total, running, cf)
}
var guestCount int
db.QueryRow(`SELECT COUNT(*) FROM guests WHERE host_id='demo-host-01'`).Scan(&guestCount)
if guestCount != 2 {
t.Errorf("guests upserted = %d, want 2", guestCount)
}
// storage_targets (slice 5) must be persisted verbatim in report_json.
var reportJSON string
if err := db.QueryRow(`SELECT report_json FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`).
Scan(&reportJSON); err != nil {
t.Fatal(err)
}
var parsed hostReportPayload
if err := json.Unmarshal([]byte(reportJSON), &parsed); err != nil {
t.Fatalf("persisted report_json does not parse: %v", err)
}
if len(parsed.StorageTargets) != 2 {
t.Errorf("persisted storage_targets = %d, want 2", len(parsed.StorageTargets))
}
if parsed.StorageTargets[0].Type != "lvmthin" || parsed.StorageTargets[0].ThinPool == nil {
t.Errorf("storage_targets[0] = %+v, want lvmthin with thin_pool", parsed.StorageTargets[0])
}
}
// TestHostStorageTarget_GoldenContract asserts the hub's hostStorageTarget mirror covers
// the golden's storage_targets[0] field-for-field (the bidirectional key-set test, the hub
// half of the cross-repo contract). It round-trips the golden element through the mirror
// struct and requires the re-marshaled key set to match exactly — neither missing a field
// the agent sends nor inventing one it doesn't.
func TestHostStorageTarget_GoldenContract(t *testing.T) {
raw, err := os.ReadFile("testdata/host-report.golden.json")
if err != nil {
t.Fatal(err)
}
var golden struct {
StorageTargets []json.RawMessage `json:"storage_targets"`
}
if err := json.Unmarshal(raw, &golden); err != nil {
t.Fatal(err)
}
if len(golden.StorageTargets) == 0 {
t.Fatal("golden has no storage_targets to check")
}
var goldenKeys map[string]any
json.Unmarshal(golden.StorageTargets[0], &goldenKeys)
var mirror hostStorageTarget
if err := json.Unmarshal(golden.StorageTargets[0], &mirror); err != nil {
t.Fatalf("golden storage target does not parse into the mirror: %v", err)
}
b, _ := json.Marshal(mirror)
var mirrorKeys map[string]any
json.Unmarshal(b, &mirrorKeys)
assertSameStorageKeys(t, "storage_targets[0]", goldenKeys, mirrorKeys)
assertSameStorageKeys(t, "storage_targets[0].smart", goldenKeys["smart"], mirrorKeys["smart"])
assertSameStorageKeys(t, "storage_targets[0].thin_pool", goldenKeys["thin_pool"], mirrorKeys["thin_pool"])
}
func TestHostBackup_GoldenContract(t *testing.T) {
raw, err := os.ReadFile("testdata/host-report.golden.json")
if err != nil {
t.Fatal(err)
}
var golden struct {
Backups []json.RawMessage `json:"backups"`
}
if err := json.Unmarshal(raw, &golden); err != nil {
t.Fatal(err)
}
if len(golden.Backups) == 0 {
t.Fatal("golden has no backups to check")
}
var goldenKeys map[string]any
json.Unmarshal(golden.Backups[0], &goldenKeys)
var mirror hostBackup
if err := json.Unmarshal(golden.Backups[0], &mirror); err != nil {
t.Fatalf("golden backup does not parse into the mirror: %v", err)
}
b, _ := json.Marshal(mirror)
var mirrorKeys map[string]any
json.Unmarshal(b, &mirrorKeys)
assertSameStorageKeys(t, "backups[0]", goldenKeys, mirrorKeys)
}
func TestHostRestoreTest_GoldenContract(t *testing.T) {
raw, err := os.ReadFile("testdata/host-report.golden.json")
if err != nil {
t.Fatal(err)
}
var golden struct {
RestoreTests []json.RawMessage `json:"restore_tests"`
}
if err := json.Unmarshal(raw, &golden); err != nil {
t.Fatal(err)
}
if len(golden.RestoreTests) == 0 {
t.Fatal("golden has no restore_tests to check")
}
var goldenKeys map[string]any
json.Unmarshal(golden.RestoreTests[0], &goldenKeys)
var mirror hostRestoreTest
if err := json.Unmarshal(golden.RestoreTests[0], &mirror); err != nil {
t.Fatalf("golden restore-test does not parse into the mirror: %v", err)
}
b, _ := json.Marshal(mirror)
var mirrorKeys map[string]any
json.Unmarshal(b, &mirrorKeys)
assertSameStorageKeys(t, "restore_tests[0]", goldenKeys, mirrorKeys)
}
func assertSameStorageKeys(t *testing.T, where string, a, b any) {
t.Helper()
ka, kb := sortedKeys(a), sortedKeys(b)
if !reflect.DeepEqual(ka, kb) {
t.Errorf("contract drift at %s:\n golden keys = %v\n mirror keys = %v", where, ka, kb)
}
}
func sortedKeys(v any) []string {
m, ok := v.(map[string]any)
if !ok {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}