592818492c
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
148 lines
5.3 KiB
Go
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, "ABCDEF"); 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))
|
|
}
|
|
}
|