package api import ( "encoding/base64" "encoding/json" "net/http" "os" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // The desired-state wire is a contract DUPLICATED with felhom-agent. testdata/desired-state.golden.json // MUST stay byte-identical with the agent's internal/hub/testdata copy. The hub serves desired_state // OPAQUELY (stores + emits the bytes), so this test proves the golden's desired_state round-trips // through admin-set → GET unchanged (the pass-through contract). func TestDesiredStateGolden_RoundTripsThroughHub(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") raw, err := os.ReadFile("testdata/desired-state.golden.json") if err != nil { t.Fatal(err) } var golden struct { DesiredState json.RawMessage `json:"desired_state"` } if err := json.Unmarshal(raw, &golden); err != nil { t.Fatal(err) } // Operator sets the golden's desired_state. if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, string(golden.DesiredState)); rr.Code != http.StatusOK { t.Fatalf("admin-set golden desired_state: %d body=%s", rr.Code, rr.Body.String()) } // The host fetches it back — byte-for-byte the same object (the hub never reshapes it). 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 { Generation int64 `json:"generation"` DesiredState json.RawMessage `json:"desired_state"` } json.Unmarshal(rr.Body.Bytes(), &got) if got.Generation != 1 { t.Errorf("generation = %d, want 1", got.Generation) } // Compare semantically (re-marshal both through a generic map to ignore whitespace). var want, have any json.Unmarshal(golden.DesiredState, &want) json.Unmarshal(got.DesiredState, &have) wb, _ := json.Marshal(want) hb, _ := json.Marshal(have) if string(wb) != string(hb) { t.Errorf("served desired_state diverged from the set golden:\n set: %s\n served: %s", wb, hb) } } // seedHost mints a host directly in the store for desired-state tests. func seedHost(t *testing.T, st *store.Store, hostID, custID, apiKey string) { t.Helper() if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: custID, APIKey: apiKey}); err != nil { t.Fatalf("UpsertHost(%s): %v", hostID, err) } } // Admin-set bumps the generation each write and the served desired-state reflects the latest body. func TestAdminSetDesiredState_BumpsGenerationAndServes(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") // Initial generation is 0 (nothing set yet). if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", ""); rr.Code != 200 { t.Fatalf("initial GET desired-state: %d", rr.Code) } // First admin-set → generation 1. rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"guests":[{"vmid":100,"run":"running"}]}`) if rr.Code != http.StatusOK { t.Fatalf("admin-set #1: %d body=%s", rr.Code, rr.Body.String()) } var set struct { Generation int64 `json:"generation"` } json.Unmarshal(rr.Body.Bytes(), &set) if set.Generation != 1 { t.Fatalf("generation after set #1 = %d, want 1", set.Generation) } // Second admin-set → generation 2 (monotonic). rr = do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"guests":[{"vmid":100,"run":"stopped"}]}`) json.Unmarshal(rr.Body.Bytes(), &set) if set.Generation != 2 { t.Fatalf("generation after set #2 = %d, want 2", set.Generation) } // GET serves the latest body + its generation. 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 { Generation int64 `json:"generation"` DesiredState json.RawMessage `json:"desired_state"` } json.Unmarshal(rr.Body.Bytes(), &got) if got.Generation != 2 { t.Errorf("served generation = %d, want 2", got.Generation) } var ds struct { Guests []struct { VMID int `json:"vmid"` Run string `json:"run"` } `json:"guests"` } if err := json.Unmarshal(got.DesiredState, &ds); err != nil { t.Fatalf("desired_state not valid JSON: %v", err) } if len(ds.Guests) != 1 || ds.Guests[0].VMID != 100 || ds.Guests[0].Run != "stopped" { t.Errorf("served desired_state = %+v, want the latest (vmid 100 stopped)", ds.Guests) } } // Admin-set requires the global key; a per-host key is refused. func TestAdminSetDesiredState_GlobalKeyOnly(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", "HKEY1", `{"x":1}`); rr.Code != http.StatusForbidden { t.Errorf("per-host key admin-set = %d, want 403", rr.Code) } if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `{"x":1}`); rr.Code != http.StatusOK { t.Errorf("global key admin-set = %d, want 200", rr.Code) } // Malformed JSON is rejected at the door. if rr := do(h, http.MethodPut, "/admin/hosts/h1/desired-state", globalKey, `not json`); rr.Code != http.StatusBadRequest { t.Errorf("malformed admin-set = %d, want 400", rr.Code) } // Unknown host → 404. if rr := do(h, http.MethodPut, "/admin/hosts/nope/desired-state", globalKey, `{}`); rr.Code != http.StatusNotFound { t.Errorf("unknown host admin-set = %d, want 404", rr.Code) } } // GET /desired-state is SELF-SCOPED: host A's key cannot read host B; the global key can read any. func TestGetDesiredState_SelfScoped(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") seedHost(t, st, "h2", "c2", "HKEY2") if _, err := st.SetHostDesired("h1", []byte(`{"guests":[]}`)); err != nil { t.Fatal(err) } // h1's key reading h1 → 200. if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", ""); rr.Code != 200 { t.Errorf("h1 reading h1 = %d, want 200", rr.Code) } // h2's key reading h1 → 403 (the headline self-scoping check). if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY2", ""); rr.Code != http.StatusForbidden { t.Errorf("h2 reading h1 = %d, want 403", rr.Code) } // Global key reading any → 200. if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", globalKey, ""); rr.Code != 200 { t.Errorf("global reading h1 = %d, want 200", rr.Code) } // No token → 401. if rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "", ""); rr.Code != http.StatusUnauthorized { t.Errorf("no token = %d, want 401", rr.Code) } } // The control envelope on a host-report carries the current generation + has_signed_ops flag. func TestHostReportEnvelope_GenerationAndSignedOps(t *testing.T) { h, st, _ := newTestHandler(t) st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}) seedHost(t, st, "h1", "c1", "HKEY") readEnvelope := func() (int64, bool) { rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1")) if rr.Code != 200 { t.Fatalf("host-report: %d body=%s", rr.Code, rr.Body.String()) } var env struct { DesiredGeneration int64 `json:"desired_generation"` HasSignedOps bool `json:"has_signed_ops"` } json.Unmarshal(rr.Body.Bytes(), &env) return env.DesiredGeneration, env.HasSignedOps } // Fresh host: generation 0, no signed ops. if gen, signed := readEnvelope(); gen != 0 || signed { t.Fatalf("fresh envelope = gen %d signed %v, want 0/false", gen, signed) } // After an admin-set: generation advances on the next heartbeat. if _, err := st.SetHostDesired("h1", []byte(`{"guests":[]}`)); err != nil { t.Fatal(err) } if gen, _ := readEnvelope(); gen != 1 { t.Errorf("envelope generation after set = %d, want 1", gen) } // After enqueueing a signed job: has_signed_ops flips true. if err := st.EnqueueSignedJob("h1", "job1", []byte("opaque-signed-blob")); err != nil { t.Fatal(err) } if _, signed := readEnvelope(); !signed { t.Errorf("has_signed_ops after enqueue = false, want true") } } // GET /jobs is self-scoped and serves the enqueued opaque blobs (oldest first). func TestGetJobs_SelfScopedAndServesBlobs(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") seedHost(t, st, "h2", "c2", "HKEY2") st.EnqueueSignedJob("h1", "jobA", []byte("blobA")) st.EnqueueSignedJob("h1", "jobB", []byte("blobB")) // h2 reading h1's jobs → 403. if rr := do(h, http.MethodGet, "/hosts/h1/jobs", "HKEY2", ""); rr.Code != http.StatusForbidden { t.Errorf("h2 reading h1 jobs = %d, want 403", rr.Code) } // h1 reading its own → 200 with both blobs, oldest first. rr := do(h, http.MethodGet, "/hosts/h1/jobs", "HKEY1", "") if rr.Code != 200 { t.Fatalf("h1 reading jobs = %d", rr.Code) } var resp struct { Jobs []struct { JobID string `json:"job_id"` BlobB64 string `json:"blob_b64"` } `json:"jobs"` } json.Unmarshal(rr.Body.Bytes(), &resp) if len(resp.Jobs) != 2 || resp.Jobs[0].JobID != "jobA" || resp.Jobs[1].JobID != "jobB" { t.Fatalf("jobs = %+v, want jobA then jobB", resp.Jobs) } blobA, _ := base64.StdEncoding.DecodeString(resp.Jobs[0].BlobB64) if string(blobA) != "blobA" { t.Errorf("jobA blob = %q, want blobA", blobA) } } // DELETE /hosts/{id}/jobs/{job_id} clears a processed job (slice 10B), self-scoped + idempotent. func TestDeleteJob_SelfScopedAndIdempotent(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") seedHost(t, st, "h2", "c2", "HKEY2") st.EnqueueSignedJob("h1", "jobA", []byte("blob")) // h2 cannot clear h1's job (self-scope). if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY2", ""); rr.Code != http.StatusForbidden { t.Errorf("h2 clearing h1's job = %d, want 403", rr.Code) } if n, _ := st.CountSignedJobs("h1"); n != 1 { t.Errorf("job was removed by an unauthorized delete (depth=%d)", n) } // h1 clears its own job → 200, queue empties. if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY1", ""); rr.Code != http.StatusOK { t.Fatalf("h1 clearing own job = %d, want 200", rr.Code) } if n, _ := st.CountSignedJobs("h1"); n != 0 { t.Errorf("queue depth after delete = %d, want 0", n) } // Idempotent: deleting an absent job is a clean 200. if rr := do(h, http.MethodDelete, "/hosts/h1/jobs/jobA", "HKEY1", ""); rr.Code != http.StatusOK { t.Errorf("idempotent delete = %d, want 200", rr.Code) } } // The admin enqueue-job endpoint (global key only) seeds the queue, reflected in has_signed_ops. func TestAdminEnqueueJob_GlobalKeyOnly(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY1") blob := base64.StdEncoding.EncodeToString([]byte("signed-op-envelope")) // per-host key → 403. if rr := do(h, http.MethodPost, "/admin/hosts/h1/jobs", "HKEY1", `{"blob_b64":"`+blob+`"}`); rr.Code != http.StatusForbidden { t.Errorf("per-host enqueue = %d, want 403", rr.Code) } // global key → 201. if rr := do(h, http.MethodPost, "/admin/hosts/h1/jobs", globalKey, `{"job_id":"j1","blob_b64":"`+blob+`"}`); rr.Code != http.StatusCreated { t.Fatalf("global enqueue = %d, want 201", rr.Code) } if n, _ := st.CountSignedJobs("h1"); n != 1 { t.Errorf("queue depth = %d, want 1", n) } }