Files
felhom.eu/hub/internal/web/selfbind_automint_test.go
T
admin b6d537d86c hub v0.67.0 — auto-minted self-bind link, post-RESET staleness, unprovisioned-offsite warning
Four small items, each a case where the hub already knew something and said
nothing. Green: build, vet, tests all pass.

(a) Self-bind link is minted automatically at customer creation AND at RESET
    completion (R-36 sub-item). The console banner tells the customer to open
    "az e-mailben kapott link"; until now that email existed only once the
    operator remembered the button, so the banner could point at something that
    did not exist — during the 2026-07-18 rehearsal the box waited ~11.7 min on
    exactly that. handleSelfBindLinkSend's body was extracted into a shared
    mintAndSendSelfBindLink core so the button and the auto-mint callers cannot
    drift apart on the honesty rules: F1 (no address -> mint nothing) and F2
    (send failed -> delete the token, never leave it live). The wrapper NEVER
    fails the operation it rides on — a create that provisioned Cloudflare,
    offsite and PBS must not 500 over a courtesy email.

    Gap found and closed while wiring it: PurgeCustomerResetDBState does NOT
    clear selfbind_tokens, so a link minted BEFORE a reset would have stayed
    live across it. A successful mint already replaces it (delete-then-insert,
    single-active); the skip paths would not have, so they now clear stale
    tokens too. Invariant: after auto-mint runs the only live link is one it
    just issued, or none.

(b) Post-RESET staleness banner (R-37). When a RESET COMPLETED after the newest
    report, every health figure on the page describes a lifecycle that no longer
    exists, and the page kept showing pre-RESET warnings as current. Narrow on
    purpose: an in-flight reset does not trigger it, and it clears itself when a
    report arrives. Ties resolve to STALE — SQLite timestamps are second-
    resolution and a same-second report almost certainly predates the reset;
    erring the other way would hide the banner exactly when it matters.

(c) Unprovisioned-offsite warning (R-36 interim). enabled==true with type=="" is
    a real, stable, silent state: provisioning is Save-triggered and the
    re-enroll auto-re-issue deliberately skips an unprovisioned target, so
    nothing self-heals it. Reuses the exact predicate the offsite re-issue
    handler already refuses on.

(d) pbsdr_reissued rendered an EMPTY flash box — the key had no template branch,
    so re-issuing PBS credentials showed a success box with no words (observed
    live 2026-07-18). Now describes what was staged plus the R-39 caveat:
    confirm `pvesm status` shows the entry active, because a converged agent can
    report `applied` while the storage still 401s.

New .flash-warn (amber, --warn tokens) for the deviation tier between success
and error — exception-color principle: only on deviation, never on a healthy
page.

Tests assert each banner is ABSENT in the nominal cases as well as present in
the deviating one — a banner that always renders is worse than none. Both
red-proofed: deleting the pbsdr_reissued branch reproduces the original empty
box; neutering the staleness predicate fails the banner assertion. New
read-only store accessor CountSelfBindTokens makes the single-active invariant
assertable.

NOT in this train: the R-39 hub-side generation-bump fix the pre-travel task
made conditional. Its condition was REFUTED (SetHostDesired bumps
unconditionally; applyPBSDR is idempotent as documented) — the real mechanism is
the agent's descriptor-hash convergence and needs its own spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 21:45:11 +02:00

113 lines
3.7 KiB
Go

package web
// v0.67.0 (R-36 sub-item) — the self-bind link is minted automatically at customer creation and at
// RESET completion, so the box's console banner („nyisd meg az e-mailben kapott link") is already
// true when the customer first reads it, instead of true-once-the-operator-remembers.
//
// The load-bearing property is that auto-minting NEVER fails the operation it rides on: a customer
// create that provisioned Cloudflare, offsite and PBS must not 500 because a courtesy email
// bounced.
import (
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func seedForMint(t *testing.T, st *store.Store, id, email string) {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: id, CustomerName: id, Domain: id + ".hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: email,
}); err != nil {
t.Fatal(err)
}
}
func liveTokenCount(t *testing.T, st *store.Store, customerID string) int {
t.Helper()
n, err := st.CountSelfBindTokens(customerID)
if err != nil {
t.Fatalf("count self-bind tokens: %v", err)
}
return n
}
func TestAutoMintSelfBindLink(t *testing.T) {
t.Run("mints and sends when an email is registered", func(t *testing.T) {
s, st := newTestServer(t)
m := &stubMailer{}
s.SetSelfBindMailer(m)
seedForMint(t, st, "acme", "ops@acme.hu")
s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation")
if m.link == "" {
t.Fatal("no self-bind link was sent")
}
if liveTokenCount(t, st, "acme") != 1 {
t.Error("expected exactly one live capability token after auto-mint")
}
})
t.Run("no registered email → nothing minted (F1 honoured)", func(t *testing.T) {
s, st := newTestServer(t)
m := &stubMailer{}
s.SetSelfBindMailer(m)
seedForMint(t, st, "acme", "")
s.autoMintSelfBindLink("acme", "", "customer creation")
if m.link != "" {
t.Error("a link was sent for a customer with no registered address")
}
if liveTokenCount(t, st, "acme") != 0 {
t.Error("a token was minted that nobody could ever receive — F1 says mint nothing")
}
})
t.Run("send failure invalidates the token (F2 honoured)", func(t *testing.T) {
s, st := newTestServer(t)
s.SetSelfBindMailer(&stubMailer{fail: true})
seedForMint(t, st, "acme", "ops@acme.hu")
s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation")
if liveTokenCount(t, st, "acme") != 0 {
t.Error("send failed but a live capability token was left behind — F2 says delete it")
}
})
t.Run("RESET does not leave a pre-RESET link live", func(t *testing.T) {
// PurgeCustomerResetDBState does NOT clear selfbind_tokens, so a link minted before a reset
// would otherwise survive it. Auto-mint must guarantee that afterwards the only live token
// is one it just issued — or none.
s, st := newTestServer(t)
s.SetSelfBindMailer(&stubMailer{})
seedForMint(t, st, "acme", "ops@acme.hu")
// A link exists from before the reset.
s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation")
if liveTokenCount(t, st, "acme") != 1 {
t.Fatal("setup: expected a pre-reset token")
}
// Now the customer loses their address and a RESET happens: the skip path must still clear it.
s.autoMintSelfBindLink("acme", "", "RESET completion")
if liveTokenCount(t, st, "acme") != 0 {
t.Error("a capability link minted BEFORE the reset is still live after it")
}
})
t.Run("no mailer configured → no token, no panic", func(t *testing.T) {
s, st := newTestServer(t) // SetSelfBindMailer never called
seedForMint(t, st, "acme", "ops@acme.hu")
s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation")
if liveTokenCount(t, st, "acme") != 0 {
t.Error("a token was minted with no mailer to deliver it")
}
})
}