package api import ( "database/sql" "encoding/json" "io" "log" "net/http" "net/http/httptest" "path/filepath" "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.StatusBadRequest { t.Errorf("oversize body status = %d, want 400", 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()) } }