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
198 lines
6.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|