Files
felhom.eu/hub/internal/web/selfbind_test.go
T
admin 9167cf53af
gates / gates (push) Successful in 23s
hub v0.118.0: the household's e-mails follow the household's language (R-558 Part A)
The hub has written every customer e-mail in Hungarian whatever the box was set
to. The box has published its language since controller v0.247.0; nothing read
it. Now it does.

Nothing an operator reads changes. The Hungarian mails are byte-identical, and
that is a diff rather than a reading: 56 goldens per language captured from
v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after
every sentence was routed through the new bundle.

- internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0.
- customerMessages/severityLabels are DERIVED from the bundle, so a sentence is
  written in one place and all 40+ tests that read those maps still work.
- Language order: last reported -> created-with -> hu. reports.language defaults
  to EMPTY, never hu: "never told us" is not "chose Hungarian".
- message_customer on POST /api/v1/event, additive and optional forever, for the
  sentences the box composes and the hub cannot translate.
- The bind page is per-language, and its `expired` state stays Hungarian: it is
  the state an unknown token lands in, so rendering a real English customer's
  token in English would make the LANGUAGE answer what the TEXT refuses to.

Two defects found inside the release:
- R-581: the newest report was picked by received_at, which has SECOND
  granularity, so same-second reports tied and the winner was arbitrary. Ordered
  by the autoincrement id now. GetCustomers() still has the shape - row open.
- R-582: the English copy-guard stems, ported word for word from Hungarian,
  convicted 141 honest sentences. The English claim is a phrase with a modal.

R-555 closed: the language allowlist entry is out of wire_contract_gate.py.
hub_copy_gate.py follows the sentences into the bundle - without that it would
have scanned four files that no longer hold any customer text and reported
success. Three new decoys incl. an innocent control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 16:20:11 +02:00

326 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// selfbind_test.go — customer self-bind (v0.66.0, R-27 slice 1), Scenarios A–F.
//
// Each red-proof below names the ONE line to break to turn a scenario red — the guard that the test
// actually pins. If a red-proof does NOT turn its scenario red, the test is hollow.
const (
testPass = "alpha beta gamma delta epsilon" // the customer retrieval passphrase (5 words)
testCode = "ABC234" // an appliance console pairing code (raw stored form)
testCodeFmt = "abc-234" // as a human might type it (lowercased, separated)
)
// selfBindSetup seeds a customer (with passphrase + email) and one registered appliance carrying the
// given console pairing code, and returns the appliance id. Distinct customers must use distinct
// codes (a code shared by two registered appliances is ambiguous → binds nothing).
func selfBindSetup(t *testing.T, st *store.Store, customerID, code string) int64 {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: customerID, CustomerName: customerID, APIKey: "k",
RetrievalPassword: testPass, Email: customerID + "@example.test",
}); err != nil {
t.Fatal(err)
}
sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host"
if _, _, err := st.RegisterAppliance("uuid-"+customerID, "bc:24:11:98:10:0e", sshKey, `{"product":"N100"}`, "aphash-"+customerID, code); err != nil {
t.Fatal(err)
}
list, _ := st.ListUnclaimedAppliances()
for _, a := range list {
if a.UUID == "uuid-"+customerID {
return a.ID
}
}
t.Fatal("seeded appliance not found")
return 0
}
// mintLink mints a self-bind token for the customer and returns the plaintext token (URL segment).
// Each call uses a fresh nonce so distinct calls yield distinct tokens (single-active still applies —
// the store deletes the prior row for the customer on each mint).
var mintNonce int
func mintLink(t *testing.T, st *store.Store, customerID string, ttl time.Duration) string {
t.Helper()
mintNonce++
token := "tok-" + customerID + "-" + string(rune('a'+mintNonce%26)) + strconv.Itoa(mintNonce)
if err := st.MintSelfBindToken(customerID, selfBindHash(token), ttl); err != nil {
t.Fatal(err)
}
return token
}
func bindGET(t *testing.T, s *Server, token string) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
s.handleBind(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
return rr
}
func bindPOST(t *testing.T, s *Server, token, code, pass string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"pairing_code": {code}, "passphrase": {pass}}
req := httptest.NewRequest("POST", "/bind/"+token, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleBind(rr, req)
return rr
}
// --- Scenario A: happy path (GET renders form; POST with both correct factors stages the bind) ---
func TestSelfBind_A_HappyPath(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "Párosító kód") || !strings.Contains(body, "action=\"/bind/"+token+"\"") {
t.Fatalf("GET did not render the entry form")
}
// A human types the code lowercased + separated and the passphrase with odd spacing — normalization
// must accept both.
rr := bindPOST(t, s, token, testCodeFmt, " Alpha Beta gamma-delta epsilon ")
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") {
t.Fatalf("POST success page not rendered: code=%d body=%q", rr.Code, rr.Body.String())
}
// The appliance is bound to the customer (same effect as an operator bind).
if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound || a.CustomerID != "acme" {
t.Fatalf("appliance not bound: %+v", a)
}
// Provenance event is customer self-bind, not operator/hub.
ev, _ := st.GetLatestEventByType("acme", "appliance_bound")
if ev == nil || ev.Source != "customer_selfbind" {
t.Fatalf("expected customer_selfbind provenance event, got %+v", ev)
}
// One-shot: the token is now consumed; a second open shows the consumed state.
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "már fel lett használva") {
t.Fatalf("token not consumed after success")
}
// Red-proof: drop the ConsumeSelfBindToken call (or make BindAppliance the only effect) → the
// token stays live and this consumed-state assertion goes red.
}
// --- Scenario B: NO ORACLE — wrong code and wrong passphrase yield the SAME generic failure ---
func TestSelfBind_B_NoOracle(t *testing.T) {
s, st := newTestServer(t)
// Two customers with DISTINCT pairing codes so the single-active mint does not cross-invalidate,
// and each has one fresh (attempt-count 1) token — the only difference between the two failure
// pages is then the token in the form action, which we normalize out.
selfBindSetup(t, st, "acme", testCode)
selfBindSetup(t, st, "acme2", "XYZ789")
t1 := mintLink(t, st, "acme", selfBindTTL) // wrong-code attempt (right passphrase)
t2 := mintLink(t, st, "acme2", selfBindTTL) // wrong-passphrase attempt (right code)
wrongCode := strings.ReplaceAll(bindPOST(t, s, t1, "ZZZ999", testPass).Body.String(), t1, "TOKEN")
wrongPass := strings.ReplaceAll(bindPOST(t, s, t2, "xyz-789", "wrong words here now").Body.String(), t2, "TOKEN")
if wrongCode != wrongPass {
t.Fatalf("failure pages differ between wrong-code and wrong-passphrase — that is an oracle")
}
// The generic failure must not leak any appliance data.
if !strings.Contains(wrongCode, "nem megfelelőek") {
t.Fatalf("failure page missing the generic banner: %q", wrongCode)
}
if strings.Contains(wrongCode, "uuid-") || strings.Contains(wrongCode, "N100") {
t.Fatalf("failure page leaked appliance data")
}
// Red-proof: give wrong-code and wrong-passphrase distinct messages/states (an oracle) → the
// wrongCode == wrongPass assertion goes red.
}
// --- Scenario C1: LOCKOUT after 5 failed attempts; a subsequent CORRECT attempt cannot bind ---
func TestSelfBind_C1_Lockout(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
for i := 1; i <= store.SelfBindMaxAttempts; i++ {
rr := bindPOST(t, s, token, "ZZZ999", "definitely wrong words indeed")
if i < store.SelfBindMaxAttempts && !strings.Contains(rr.Body.String(), "nem megfelelőek") {
t.Fatalf("attempt %d should re-render the form with a failure, got %q", i, rr.Body.String())
}
}
// The 5th failure locked it: even the CORRECT secrets now bind nothing.
rr := bindPOST(t, s, token, testCodeFmt, testPass)
if !strings.Contains(rr.Body.String(), "zárolva") {
t.Fatalf("token not locked after %d failures: %q", store.SelfBindMaxAttempts, rr.Body.String())
}
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Fatalf("a locked link still bound the appliance: %+v", a)
}
// Red-proof: remove the `locked = attempts >= SelfBindMaxAttempts` lock (never lock) → the correct
// post-lockout POST binds and both the "zárolva" and still-registered assertions go red.
}
// --- Scenario C4: SINGLE-ACTIVE per customer — re-minting kills the prior link ---
func TestSelfBind_C4_SingleActive(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
first := mintLink(t, st, "acme", selfBindTTL)
second := mintLink(t, st, "acme", selfBindTTL) // re-mint for the SAME customer
if body := bindGET(t, s, first).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
t.Fatalf("the first link still resolves after a re-mint (not single-active): %q", body)
}
if body := bindGET(t, s, second).Body.String(); !strings.Contains(body, "Párosító kód") {
t.Fatalf("the freshly-minted link does not render the form: %q", body)
}
// Red-proof: drop the `DELETE FROM selfbind_tokens WHERE customer_id` in MintSelfBindToken → the
// first link still resolves and the érvénytelen assertion goes red.
}
// --- Scenario D: the operator auth gate is intact — self-bind's exemption did NOT open other routes ---
func TestSelfBind_D_AuthGateIntact(t *testing.T) {
s, st := newAuthServer(t)
selfBindSetup(t, st, "acme", testCode)
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
for _, path := range []string{"/", "/hosts", "/customers/acme", "/configuration", "/offsite"} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/login" {
t.Fatalf("gated route %s not redirected to /login: code=%d loc=%q", path, rr.Code, rr.Header().Get("Location"))
}
}
// Red-proof: this is the companion to Scenario E — widening isPublicBindPath turns THIS red too.
}
// --- Scenario E: THE TRAP — /bind/ is exempt from operator auth, matched TIGHTLY (no leak/traversal) ---
func TestSelfBind_E_TheTrap(t *testing.T) {
s, st := newAuthServer(t)
selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
// The public bind link renders WITHOUT a login (the exemption works).
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/"+token, nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Párosító kód") {
t.Fatalf("public /bind/ link was gated or not rendered: code=%d", rr.Code)
}
// A sibling prefix must NOT be exempt: /bindsecret is still gated (tight trailing-slash match).
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bindsecret", nil))
if rr.Code != http.StatusFound {
t.Fatalf("/bindsecret leaked through the exemption (prefix not tight): code=%d", rr.Code)
}
// Traversal through the exempt prefix must not reach a gated handler: it stays inside handleBind,
// which rejects a token containing '/', never touching /hosts.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/../hosts", nil))
if strings.Contains(rr.Body.String(), "Unclaimed appliances") || strings.Contains(rr.Body.String(), "No hosts enrolled") {
t.Fatalf("path traversal through /bind/ reached the hosts page")
}
// Red-proof: widen isPublicBindPath to strings.HasPrefix(path, "/bind") (drop the slash) → the
// /bindsecret gated assertion goes red; broaden it further and Scenario D goes red too.
}
// --- Scenario F: EXPIRY falls back — an expired link binds nothing, even with correct factors ---
func TestSelfBind_F_ExpiryFallsBack(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", -1*time.Hour) // already expired
if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") {
t.Fatalf("expired link did not render the expired state")
}
// Even the CORRECT secrets on an expired link bind nothing (operator-bind fallback is unchanged).
bindPOST(t, s, token, testCodeFmt, testPass)
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Fatalf("an expired link still bound the appliance: %+v", a)
}
// Red-proof: drop the `expires_at > datetime('now')` guard in ConsumeSelfBindToken AND the
// tok.Expired() gate → the expired POST binds and the still-registered assertion goes red.
}
// --- Scenario (F1/F2): the operator MINT honesty paths ---
// F1: a customer with no registered email → nothing minted, LOUD flash.
func TestSelfBind_MintNoEmail(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "noemail", APIKey: "k", RetrievalPassword: testPass}); err != nil {
t.Fatal(err)
}
s.SetSelfBindMailer(&stubMailer{})
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/customers/noemail/selfbind-link", nil)
s.handleSelfBindLinkSend(rr, req, "noemail")
if rr.Code != http.StatusSeeOther || !strings.Contains(rr.Header().Get("Location"), "selfbind-no-email") {
t.Fatalf("no-email mint should redirect with selfbind-no-email: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
}
if tok, _ := st.SelfBindTokenByHash(selfBindHash("x")); tok != nil {
t.Fatal("a token was minted despite no email")
}
}
// F2: the email send fails → the just-minted token is deleted (not left silently live).
func TestSelfBind_MintSendFailsCleansUp(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
stub := &stubMailer{fail: true}
s.SetSelfBindMailer(stub)
rr := httptest.NewRecorder()
s.handleSelfBindLinkSend(rr, httptest.NewRequest("POST", "/customers/acme/selfbind-link", nil), "acme")
if !strings.Contains(rr.Header().Get("Location"), "selfbind-send-failed") {
t.Fatalf("send failure should redirect with selfbind-send-failed: %q", rr.Header().Get("Location"))
}
// The token that was minted for the (failed) send is gone — not left silently live (F2 cleanup).
// Recover the token from the link the mailer was handed, and confirm it no longer resolves.
tokenFromLink := stub.link[strings.LastIndexByte(stub.link, '/')+1:]
if tokenFromLink == "" {
t.Fatal("mailer was never handed a link")
}
if tok, _ := st.SelfBindTokenByHash(selfBindHash(tokenFromLink)); tok != nil {
t.Fatal("a failed send left a live token behind")
}
}
// stubMailer records the last send and can be told to fail.
type stubMailer struct {
fail bool
link string
}
func (m *stubMailer) SendSelfBindEmail(customerID, email, link string) error {
m.link = link
if m.fail {
return errStubSend
}
return nil
}
var errStubSend = &stubErr{}
type stubErr struct{}
func (*stubErr) Error() string { return "stub send failure" }
// newAuthServer is newTestServer with an operator password configured, so RequireAuth is live.
func newAuthServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
s, st := newTestServer(t)
h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
s.configPasswordHash = string(h)
return s, st
}