fix(hub): slice-3 follow-ups — /host-report 413 oversize + contract golden (v0.7.1)

- handleHostReport: read maxHostReportBytes+1 (4 MiB const) and reject oversize with
  413 instead of silent LimitReader truncation. Controller handleReport (1 MiB) is
  unchanged. Test asserts 413.
- contract: hub/internal/api/testdata/host-report.golden.json (byte-identical with
  felhom-agent's copy) + TestHostReport_GoldenContract drives the real handler and
  asserts 200 + denorm + both guests upserted.
- CHANGELOG v0.7.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 18:31:44 +02:00
parent 23611c20ef
commit 4be3bdf486
5 changed files with 144 additions and 5 deletions
+44 -2
View File
@@ -7,6 +7,7 @@ import (
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -152,8 +153,8 @@ func TestHandleHostReport_OversizeRejected(t *testing.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.StatusBadRequest {
t.Errorf("oversize body status = %d, want 400", rr.Code)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversize body status = %d, want 413", rr.Code)
}
}
@@ -188,3 +189,44 @@ func TestAdminCreateHost(t *testing.T) {
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)
}
}