Files
felhom.eu/hub/internal/web/appliances_test.go
T
admin 36c5cd5fdf hub v0.62.0 + scripts v1.19.0 — R-21 slice C: the universal secret-free ISO
A generic ISO carries NO customer secret. The box registers itself at the hub
as an unclaimed appliance; the operator binds it to a customer; the hub delivers
the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path.

Hub (v0.62.0):
- store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set
  is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only.
  Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard.
- api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP
  rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound
  / 200 deliver-once / 410 delivered). Passphrase read live, never logged.
- web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer
  picker, host count display-only) + DISCARD; SSH host-key fingerprints; events.
- Red-proofs: one-shot delivery + register idempotency (both proven red);
  404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate.

Scripts (v1.19.0):
- felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase)
  = slice-A path, byte-identical, only branched around. Pairing (generic) =
  register + poll (RestartSec=30 is the poll timer); on delivery write the env
  0600 and fall through to direct. Secrets + token shredded on success.
- build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename,
  manifest mode=pairing. profiles/generic.profile (new).
- test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing
  register/poll + delivery handoff — all green in a debian container.
2026-07-17 15:07:31 +02:00

148 lines
5.3 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-21 slice C — the operator unclaimed-appliance surface: render + bind/discard.
func seedAppliance(t *testing.T, st *store.Store, uuid, macSet string) int64 {
t.Helper()
// a real ed25519 host key line so the fingerprint helper has something to parse
sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host"
hw := `{"product":"Intel N100 mini","cpu":"Intel(R) N100","mem_kb":16150372}`
if _, err := st.RegisterAppliance(uuid, macSet, sshKey, hw, "hash-"+uuid); err != nil {
t.Fatalf("register appliance: %v", err)
}
list, err := st.ListUnclaimedAppliances()
if err != nil {
t.Fatal(err)
}
for _, a := range list {
if a.UUID == uuid && a.MACSet == macSet {
return a.ID
}
}
t.Fatal("seeded appliance not found")
return 0
}
func renderHosts(t *testing.T, s *Server) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleHostsList(rr, httptest.NewRequest("GET", "/hosts", nil))
if rr.Code != 200 {
t.Fatalf("hosts page = %d", rr.Code)
}
return rr.Body.String()
}
func TestAppliances_UnclaimedSectionRenders(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme Kft", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
t.Fatal(err)
}
seedAppliance(t, st, "uuid-vis", "bc:24:11:98:10:0e,bc:24:11:98:10:0f")
html := renderHosts(t, s)
for _, want := range []string{
"Unclaimed appliances", "uuid-vis", "bc:24:11:98:10:0e",
"Intel N100 mini", "SHA256:", // hw + a computed SSH fingerprint
`action="/appliances/`, "/bind", "/discard",
`Acme Kft (0 hosts)`, // the picker shows host counts (display only)
} {
if !strings.Contains(html, want) {
t.Errorf("unclaimed section missing %q", want)
}
}
}
func TestAppliances_BindStagesDelivery(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
t.Fatal(err)
}
id := seedAppliance(t, st, "uuid-bind", "bc:24:11:98:10:0e")
form := url.Values{"customer_id": {"acme"}, "mode": {"appliance"}, "extra_args": {"--cores 4"}}
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleApplianceBind(rr, req, id)
if rr.Code != http.StatusSeeOther {
t.Fatalf("bind = %d (%s), want 303", rr.Code, rr.Body.String())
}
// The appliance is now bound with the staged delivery.
a, _ := st.GetAppliance(id)
if a.Status != store.ApplianceBound || a.CustomerID != "acme" || a.InstallMode != "appliance" || a.ExtraArgs != "--cores 4" {
t.Fatalf("bind did not stage the delivery: %+v", a)
}
// Audit event recorded (a customer scopes it now).
if ev, _ := st.GetLatestEventByType("acme", "appliance_bound"); ev == nil {
t.Error("no appliance_bound event recorded")
}
// It leaves the unclaimed section as a bound row (still shown until delivered).
if !strings.Contains(renderHosts(t, s), "bound → Acme") {
t.Error("bound appliance not shown as bound in the UI")
}
}
// Bind must NOT gate on the customer's host count (a post-RESET / drill customer is hostless).
func TestAppliances_BindDoesNotGateOnHostCount(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "hostless", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
t.Fatal(err)
}
id := seedAppliance(t, st, "uuid-h", "bc:24:11:98:10:0e")
form := url.Values{"customer_id": {"hostless"}}
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleApplianceBind(rr, req, id)
if rr.Code != http.StatusSeeOther {
t.Fatalf("bind to hostless customer = %d, want 303 (host count is display-only)", rr.Code)
}
}
func TestAppliances_BindUnknownCustomerRejected(t *testing.T) {
s, st := newTestServer(t)
id := seedAppliance(t, st, "uuid-u", "bc:24:11:98:10:0e")
form := url.Values{"customer_id": {"ghost"}}
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleApplianceBind(rr, req, id)
if rr.Code != http.StatusBadRequest {
t.Fatalf("bind to unknown customer = %d, want 400", rr.Code)
}
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Error("a rejected bind still mutated the appliance")
}
}
func TestAppliances_Discard(t *testing.T) {
s, st := newTestServer(t)
id := seedAppliance(t, st, "uuid-d", "bc:24:11:98:10:0e")
req := httptest.NewRequest("POST", "/appliances/x/discard", nil)
rr := httptest.NewRecorder()
s.handleApplianceDiscard(rr, req, id)
if rr.Code != http.StatusSeeOther {
t.Fatalf("discard = %d, want 303", rr.Code)
}
a, _ := st.GetAppliance(id)
if a.Status != store.ApplianceDiscarded {
t.Fatalf("discard did not set status: %+v", a)
}
// No longer in the unclaimed list.
list, _ := st.ListUnclaimedAppliances()
if len(list) != 0 {
t.Errorf("discarded appliance still unclaimed: %d", len(list))
}
}