e54f882e70
Serve operator intent to authenticated hosts: PUT /admin/hosts/{id}/desired-state
(global key) bumps desired_generation; GET /hosts/{id}/desired-state + /jobs are
per-host self-scoped; the host-report envelope now carries the real generation +
has_signed_ops. New signed_jobs table + store methods. Desired-state stored/served
opaquely (agent owns the schema). Cross-repo golden (envelope + desired-state)
byte-identical with felhom-agent; doc 03 §4/§9 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
9.8 KiB
Go
265 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|