Files
felhom.eu/hub/internal/web/customer_state_banners_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

198 lines
6.7 KiB
Go

package web
// v0.67.0 — the two "the hub knows but never said so" banners on the customer page, plus the
// self-bind auto-mint. Each test asserts the banner is ABSENT in the nominal case as well as
// present in the deviating one: a banner that renders unconditionally is worse than none, because
// operators stop reading it.
import (
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
const staleBannerMarker = "No data since the RESET"
const offsiteBannerMarker = "Offsite is enabled but was never provisioned"
func contains(html, needle string) bool { return strings.Contains(html, needle) }
// renderCustomerPageWithQuery renders the page with a query string (the flash arrives that way).
func renderCustomerPageWithQuery(t *testing.T, s *Server, customerID, query string) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/"+customerID+query, nil), customerID)
if rr.Code != 200 {
t.Fatalf("customer page status = %d", rr.Code)
}
return rr.Body.String()
}
// flashSuccessBox captures the success flash div's inner content so a branch-less flash key
// (which renders the div with nothing in it) can be told apart from one that has text.
var flashSuccessBox = regexp.MustCompile(`(?s)<div class="flash flash-success">(.*?)</div>`)
func flashBoxIsEmpty(html string) bool {
m := flashSuccessBox.FindStringSubmatch(html)
if m == nil {
return true
}
return strings.TrimSpace(m[1]) == ""
}
func seedCustomer(t *testing.T, st *store.Store, id, configJSON string) {
t.Helper()
cfg := &store.CustomerConfig{
CustomerID: id, CustomerName: id, Domain: id + ".hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
Email: id + "@example.com",
}
if configJSON != "" {
cfg.ConfigJSON = configJSON
}
if err := st.SaveCustomerConfig(cfg); err != nil {
t.Fatal(err)
}
}
// --- R-37: post-RESET staleness -----------------------------------------------------------------
func TestCustomerPage_StaleSinceReset(t *testing.T) {
t.Run("reset newer than the last report → banner", func(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "acme", "")
if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
// A reset that STARTED and COMPLETED after that report.
id, err := st.StartCustomerReset("acme", true)
if err != nil {
t.Fatal(err)
}
if err := st.FinishCustomerReset(id); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "acme")
if !contains(html, staleBannerMarker) {
t.Error("a completed RESET newer than the newest report must raise the staleness banner — " +
"every health figure on the page describes a lifecycle that no longer exists")
}
// The Hungarian phrasing the customer-facing surface will use is quoted for the operator.
if !contains(html, "RESET óta nincs adat") {
t.Error("banner should quote „RESET óta nincs adat\"")
}
})
t.Run("no reset at all → no banner", func(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "acme", "")
if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
if html := renderCustomerPage(t, s, "acme"); contains(html, staleBannerMarker) {
t.Error("a customer that was never reset must not show the staleness banner")
}
})
t.Run("reset still running (never completed) → no banner", func(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "acme", "")
if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
if _, err := st.StartCustomerReset("acme", true); err != nil { // started, NOT finished
t.Fatal(err)
}
if html := renderCustomerPage(t, s, "acme"); contains(html, staleBannerMarker) {
t.Error("an in-flight reset must not claim the data is stale — only a COMPLETED one does")
}
})
t.Run("report arrived after the reset → banner clears", func(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "acme", "")
id, err := st.StartCustomerReset("acme", true)
if err != nil {
t.Fatal(err)
}
if err := st.FinishCustomerReset(id); err != nil {
t.Fatal(err)
}
// SQLite second-resolution timestamps: make the report unambiguously later.
time.Sleep(1100 * time.Millisecond)
if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
if html := renderCustomerPage(t, s, "acme"); contains(html, staleBannerMarker) {
t.Error("once the box reports again the page is current — the banner must clear itself, " +
"otherwise it becomes permanent furniture")
}
})
}
// --- R-36 interim: offsite enabled but never provisioned ----------------------------------------
func TestCustomerPage_OffsiteUnprovisioned(t *testing.T) {
cases := []struct {
name string
configJSON string
wantBanner bool
}{
{
name: "enabled with no type → the silent state, banner",
configJSON: `{"offsite":{"enabled":true}}`,
wantBanner: true,
},
{
name: "enabled and provisioned → no banner",
configJSON: `{"offsite":{"enabled":true,"type":"storagebox"}}`,
wantBanner: false,
},
{
name: "disabled → no banner (nothing was promised)",
configJSON: `{"offsite":{"enabled":false}}`,
wantBanner: false,
},
{
name: "no offsite key at all → no banner",
configJSON: `{}`,
wantBanner: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "acme", tc.configJSON)
html := renderCustomerPage(t, s, "acme")
if got := contains(html, offsiteBannerMarker); got != tc.wantBanner {
t.Errorf("banner present = %v, want %v (config %s)", got, tc.wantBanner, tc.configJSON)
}
})
}
}
// --- the pbsdr_reissued flash had no text at all (rendered an empty box) -------------------------
func TestCustomerPage_EveryRedirectFlashRendersText(t *testing.T) {
// Every flash key the server can redirect with must produce visible text. pbsdr_reissued
// shipped without a branch, so the operator got an empty success box after re-issuing PBS
// credentials — observed live 2026-07-18.
for _, flash := range []string{"pbsdr_reissued", "offsite_reissued", "reset_done", "created"} {
t.Run(flash, func(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "acme", "")
html := renderCustomerPageWithQuery(t, s, "acme", "?flash="+flash)
if !contains(html, `class="flash flash-success"`) {
t.Fatalf("flash %q rendered no success box at all", flash)
}
if flashBoxIsEmpty(html) {
t.Errorf("flash %q renders an EMPTY box — the operator is shown a confirmation with no words", flash)
}
})
}
}