Files
felhom-agent/internal/hub/desired_client_test.go
T
admin 8ecf8929fb slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)
The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:02:59 +02:00

78 lines
2.5 KiB
Go

package hub
import (
"context"
"net/http"
"testing"
)
// FetchDesiredState GETs the SELF-SCOPED path with the bearer token and decodes the response.
func TestFetchDesiredState_PathAuthAndDecode(t *testing.T) {
var gotPath, gotAuth, gotMethod string
c := testClient(func(r *http.Request) (*http.Response, error) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotMethod = r.Method
return httpResp(200, `{"generation":4,"desired_state":{"guests":[
{"vmid":100,"run":"running"},
{"vmid":200,"decommission":true}
],"restore_directive":{"mode":"guest_loss","archive":"local:backup/x","vmid":100}}}`), nil
})
resp, err := c.FetchDesiredState(context.Background())
if err != nil {
t.Fatalf("FetchDesiredState: %v", err)
}
// Self-scoped: the client only ever fetches ITS OWN host (the configured host_id).
if gotMethod != http.MethodGet || gotPath != "/api/v1/hosts/demo-host-01/desired-state" {
t.Errorf("request = %s %s, want GET /api/v1/hosts/demo-host-01/desired-state", gotMethod, gotPath)
}
if gotAuth != "Bearer super-secret-bearer-key" {
t.Errorf("auth header = %q, want the per-host bearer", gotAuth)
}
if resp.Generation != 4 {
t.Errorf("generation = %d, want 4", resp.Generation)
}
if len(resp.DesiredState.Guests) != 2 {
t.Fatalf("guests = %d, want 2", len(resp.DesiredState.Guests))
}
if resp.DesiredState.Guests[0].Run != "running" || !resp.DesiredState.Guests[1].Decommission {
t.Errorf("guests = %+v", resp.DesiredState.Guests)
}
// Forward-compat restore_directive is carried through (consumed in 10D).
if resp.DesiredState.RestoreDirective == nil || resp.DesiredState.RestoreDirective.Mode != "guest_loss" {
t.Errorf("restore_directive = %+v, want carried (mode guest_loss)", resp.DesiredState.RestoreDirective)
}
}
// A non-2xx response is a typed HTTPError (e.g. a 403 self-scope refusal from the hub).
func TestFetchDesiredState_HTTPError(t *testing.T) {
c := testClient(func(r *http.Request) (*http.Response, error) {
return httpResp(403, `Forbidden: host_id mismatch`), nil
})
_, err := c.FetchDesiredState(context.Background())
if err == nil {
t.Fatal("expected an error on 403")
}
var he *HTTPError
if !asHTTPError(err, &he) || he.StatusCode != 403 {
t.Errorf("err = %v, want HTTPError 403", err)
}
}
func asHTTPError(err error, target **HTTPError) bool {
for err != nil {
if he, ok := err.(*HTTPError); ok {
*target = he
return true
}
type unwrapper interface{ Unwrap() error }
if u, ok := err.(unwrapper); ok {
err = u.Unwrap()
} else {
return false
}
}
return false
}