b6d537d86c
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
113 lines
3.7 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|