hub v0.126.0: fresh connect link from the old one (R-719); day-one mails (R-723); bind-page wording (R-725); volunteer guide current (R-722); ep0 cleanup and release evidence
gates / gates (push) Successful in 29s
gates / gates (push) Successful in 29s
Red-proofs RP40-RP42. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
## v0.126.0 — a fresh connect link from the old one (R-719); no "recovered" for a new box and no first-hour tier-skip mail (R-723); the bind page names who hands over the phrase (R-725) (2026-09-30)
|
||||
|
||||
- **R-719:** a box that registers is UNCLAIMED — the registration carries uuid, MACs, host keys and hardware, nothing
|
||||
of a customer (measured, `internal/api/appliance.go`) — so the hub cannot send "your" link when "your" box
|
||||
registers without mailing every waiting customer. Instead the self-bind page's **expired** and **already used**
|
||||
states offer „Új linket kérek" (`POST /bind/<token>/resend`): the hub mints and mails a fresh link to the address
|
||||
REGISTERED for that link's customer, only when the customer has no box, at most once an hour per customer. The
|
||||
answer is the same page in the default language whatever the token was (no oracle — `TestBindExpiredIsAlwaysDefaultLanguage`
|
||||
now compares the two pages with each visitor's own token normalised; every other byte must still match).
|
||||
- **R-723:** `node_recovered` is not emitted when the customer's current host was enrolled after the outage began
|
||||
(or, after a hub restart, within the stale threshold) — a new box is not a recovery; `backup_tier_skipped` in a box's
|
||||
first hour is recorded, not mailed (provisioning in flight). Real recoveries and old boxes' skips still mail.
|
||||
- **R-725 (hub half):** the bind page's passphrase hint says „amelyet a Felhom üzemeltetőjétől kaptál", as the mail
|
||||
and the console do.
|
||||
- Tests: `TestR719_*`, `TestR723_*`. Red-proofs RP40–RP42, each seen failing on an assertion.
|
||||
|
||||
## v0.125.0 — the customer delete stops promising a Cloudflare removal it never did (2026-09-25, R-688)
|
||||
|
||||
- **Customer delete dialog** (`web/customer_delete.go`, `templates/customer_unified.html`): no leg of the cascade
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"bind.hint.pairing": "It appears on the box's monitor, after the install.",
|
||||
"bind.label.passphrase": "Owner passphrase",
|
||||
"bind.placeholder.passphrase": "the words, with hyphens or spaces",
|
||||
"bind.hint.passphrase": "The word phrase you received from your operator during setup. It proves the account is yours.",
|
||||
"bind.hint.passphrase": "The five-word phrase you received from your Felhom operator. It proves the account is yours.",
|
||||
"bind.submit": "Link the box",
|
||||
"bind.note": "For safety the link locks after 5 failed attempts. If that happens, contact support.",
|
||||
"bind.success.lead": "Linked successfully.",
|
||||
@@ -82,5 +82,9 @@
|
||||
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring",
|
||||
"mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version",
|
||||
"mail.event.app_update_held": "%s: the app is stopped and needs a restore",
|
||||
"mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing"
|
||||
"mail.event.app_stopped_unhealthy": "%s: the app was stopped because it kept crashing",
|
||||
"bind.resend.hint": "If this was your link and your box is not linked yet, we send a fresh link to the e-mail address you gave Felhom.",
|
||||
"bind.resend.button": "Send me a new link",
|
||||
"bind.resent.lead": "Done.",
|
||||
"bind.resent.body": "If this was a real link and your box is not linked yet, a new e-mail reaches your registered address within a few minutes. If it does not, contact support."
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"bind.hint.pairing": "A doboz monitorán jelenik meg, a telepítés után.",
|
||||
"bind.label.passphrase": "Tulajdonosi jelmondat",
|
||||
"bind.placeholder.passphrase": "öt szó, kötőjellel vagy szóközzel",
|
||||
"bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.",
|
||||
"bind.hint.passphrase": "Az öt szóból álló kifejezés, amelyet a Felhom üzemeltetőjétől kaptál. Ez igazolja, hogy a fiók a tiéd.",
|
||||
"bind.submit": "Összekötés",
|
||||
"bind.note": "Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.",
|
||||
"bind.success.lead": "Sikeres összekötés.",
|
||||
@@ -82,5 +82,9 @@
|
||||
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring",
|
||||
"mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut",
|
||||
"mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges",
|
||||
"mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott"
|
||||
"mail.event.app_stopped_unhealthy": "%s: az alkalmazást leállítottuk, mert újra és újra összeomlott",
|
||||
"bind.resend.hint": "Ha ez a te linked volt, és a dobozod még nincs összekötve, új linket küldünk arra az e-mail címre, amelyet a Felhomnál megadtál.",
|
||||
"bind.resend.button": "Új linket kérek",
|
||||
"bind.resent.lead": "Kész.",
|
||||
"bind.resent.body": "Ha ez egy valódi hivatkozás volt, és a dobozod még nincs összekötve, néhány percen belül új e-mailt kapsz a regisztrált címedre. Ha nem jön, szólj az ügyfélszolgálatnak."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
func hostFor(t *testing.T, st *store.Store, path string, createdAgo time.Duration) {
|
||||
t.Helper()
|
||||
if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`,
|
||||
time.Now().UTC().Add(-createdAgo).Format("2006-01-02 15:04:05")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-723 (v0.126.0) — the 2026-09-29 shape: the customer's previous box went silent 12 days ago (the hub
|
||||
// seeds the customer as down), a NEW box enrolls and sends its first report. The CONSEQUENCE asserted: no
|
||||
// `node_recovered` (it is the operator mail „recovered" for an outage the new box never had).
|
||||
// COMPANION RED-PROOF: make isNewBox return false → the event list is [node_recovered].
|
||||
func TestR723_ANewBoxIsNotARecovery(t *testing.T) {
|
||||
st, path := seedStalenessCustomer(t, "ok", 12*24*time.Hour)
|
||||
sc, events := newChecker(t, st) // seeds c1 as down (the old box's silence)
|
||||
if sc.GetState("c1") != "down" {
|
||||
t.Fatalf("setup: want down, got %q", sc.GetState("c1"))
|
||||
}
|
||||
hostFor(t, st, path, 2*time.Minute) // the new box enrolled 2 min ago
|
||||
saveReportAged(t, st, path, "ok", 0) // …and reported
|
||||
sc.Check()
|
||||
if len(*events) != 0 {
|
||||
t.Fatalf("a new box's first report emitted %v", *events)
|
||||
}
|
||||
if sc.GetState("c1") != "ok" {
|
||||
t.Fatalf("state %q, want ok", sc.GetState("c1"))
|
||||
}
|
||||
}
|
||||
|
||||
// Control: the SAME box, enrolled long ago, coming back after an outage IS a recovery and still mails.
|
||||
func TestR723_AnOldBoxComingBackIsStillARecovery(t *testing.T) {
|
||||
st, path := seedStalenessCustomer(t, "ok", 12*24*time.Hour)
|
||||
sc, events := newChecker(t, st)
|
||||
hostFor(t, st, path, 40*24*time.Hour)
|
||||
saveReportAged(t, st, path, "ok", 0)
|
||||
sc.Check()
|
||||
if len(*events) != 1 || (*events)[0] != "node_recovered" {
|
||||
t.Fatalf("a real recovery must still emit node_recovered, got %v", *events)
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,19 @@ func (sc *StalenessChecker) Check() {
|
||||
continue
|
||||
}
|
||||
|
||||
// R-723 (v0.126.0): a customer's NEW box is not a recovery. Staleness is tracked per CUSTOMER, so a
|
||||
// customer whose previous box went silent reads stale/down, and the new box's first report used to
|
||||
// send `node_recovered` for an outage the new box never had (measured 2026-09-29: tester-1, silent 12
|
||||
// days, new box enrolled 19:20:07Z, operator mail „recovered" 2 s after its first report). The
|
||||
// discriminator is the host record: enrolled AFTER the outage began (or, when the hub restarted during
|
||||
// the outage and has no start time, within the stale threshold) → a first observation, no event.
|
||||
if newState == "ok" && (oldState == "stale" || oldState == "down") && sc.isNewBox(c.CustomerID) {
|
||||
sc.logger.Printf("[INFO] Staleness: %s → ok on the first reports of a NEW box — not a recovery, no event", c.CustomerID)
|
||||
sc.states[c.CustomerID] = newState
|
||||
delete(sc.downtimeStart, c.CustomerID)
|
||||
continue
|
||||
}
|
||||
|
||||
// State transition — emit event
|
||||
sc.states[c.CustomerID] = newState
|
||||
if newState == "stale" && oldState == "ok" {
|
||||
@@ -250,3 +263,15 @@ func formatDuration(d time.Duration) string {
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
|
||||
// isNewBox reports whether the customer's current host was enrolled after its outage began (R-723).
|
||||
func (sc *StalenessChecker) isNewBox(customerID string) bool {
|
||||
h, err := sc.store.GetHostByCustomer(customerID)
|
||||
if err != nil || h == nil || h.CreatedAt.IsZero() {
|
||||
return false // no evidence of a new box → keep the old behaviour (a real recovery mails)
|
||||
}
|
||||
if since, ok := sc.downtimeStart[customerID]; ok {
|
||||
return h.CreatedAt.After(since)
|
||||
}
|
||||
return time.Since(h.CreatedAt) < sc.threshold
|
||||
}
|
||||
|
||||
@@ -531,6 +531,18 @@ func (d *Dispatcher) processOperator(customerID, eventType, severity, message, d
|
||||
cooldownTierSuffix(detailsJSON) + cooldownRunSuffix(detailsJSON) +
|
||||
cooldownStackSuffix(eventType, detailsJSON) + cooldownStorageSuffix(eventType, detailsJSON)
|
||||
window := operatorCooldownFor(eventType)
|
||||
// R-723 (v0.126.0): a whole-guest tier skipped for absent storage in a box's FIRST HOUR is provisioning
|
||||
// still in flight, not a fault (measured 2026-09-29: the first local backup ran 7 min after enrolment,
|
||||
// the off-site descriptor arrived with the next 15-min host report, the off-site tier then backed up
|
||||
// fine — and the operator had been mailed „skipped"). Recorded, not mailed.
|
||||
if eventType == "backup_tier_skipped" && d.hostEnrolledWithin(customerID, firstHourOfABox) {
|
||||
if err := d.store.LogNotification(customerID, eventType, severity, message,
|
||||
"suppressed", "first hour of a new box (R-723)", "operator"); err != nil {
|
||||
d.logger.Printf("[WARN] Failed to record suppressed operator notification for %s/%s: %v", customerID, eventType, err)
|
||||
}
|
||||
d.logger.Printf("[INFO] Operator email suppressed for %s/%s — the box enrolled less than %s ago", customerID, eventType, firstHourOfABox)
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
if last, ok := d.opCooldowns[cooldownKey]; ok && time.Since(last) < window {
|
||||
d.mu.Unlock()
|
||||
@@ -856,3 +868,18 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error {
|
||||
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "sent", "", "customer")
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstHourOfABox is how long after enrolment a whole-guest tier skip is provisioning, not a fault (R-723).
|
||||
const firstHourOfABox = time.Hour
|
||||
|
||||
// hostEnrolledWithin reports whether the customer's current host was enrolled less than d ago.
|
||||
func (d *Dispatcher) hostEnrolledWithin(customerID string, within time.Duration) bool {
|
||||
if d.store == nil {
|
||||
return false
|
||||
}
|
||||
h, err := d.store.GetHostByCustomer(customerID)
|
||||
if err != nil || h == nil || h.CreatedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return time.Since(h.CreatedAt) < within
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// storeWithHost opens a store with customer c1 and one host enrolled `ago` before now.
|
||||
func storeWithHost(t *testing.T, ago time.Duration) *store.Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "d.db")
|
||||
st, err := store.New(path, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"})
|
||||
if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`,
|
||||
time.Now().UTC().Add(-ago).Format("2006-01-02 15:04:05")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
const tierSkipMsg = "Whole-guest backup tier felhom-pbs skipped: its storage does not exist on the host (never provisioned or removed). No app was stopped for it."
|
||||
|
||||
// R-723 (v0.126.0) — the 2026-09-29 shape: the first whole-guest run fired 7 min after enrolment, before
|
||||
// the off-site descriptor arrived, and the operator was mailed „skipped". The CONSEQUENCE asserted: no
|
||||
// operator mail in a box's first hour.
|
||||
// COMPANION RED-PROOF: drop the first-hour check in processOperator → 1 operator mail.
|
||||
func TestR723_FirstHourTierSkipIsNotMailed(t *testing.T) {
|
||||
st := storeWithHost(t, 7*time.Minute)
|
||||
d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
|
||||
mails := captureSeam(d)
|
||||
d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller")
|
||||
if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 0 {
|
||||
t.Fatalf("a first-hour tier skip mailed the operator (%d mails)", len(op))
|
||||
}
|
||||
}
|
||||
|
||||
// Control: the same skip on a box enrolled a week ago is a real provisioning gap and still mails.
|
||||
func TestR723_TierSkipOnAnOldBoxStillMails(t *testing.T) {
|
||||
st := storeWithHost(t, 7*24*time.Hour)
|
||||
d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
|
||||
mails := captureSeam(d)
|
||||
d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller")
|
||||
if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 1 {
|
||||
t.Fatalf("an old box's tier skip must still mail the operator, got %d", len(op))
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ func TestBindExpiredIsAlwaysDefaultLanguage(t *testing.T) {
|
||||
// A token that was never real.
|
||||
unknown := getBind(t, s, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
|
||||
// v0.126.0 (R-719): the expired page echoes the visitor's OWN token into its „Új linket kérek" form. The
|
||||
// visitor already holds that token (it is the URL they opened), so it discloses nothing; every OTHER
|
||||
// byte must still be identical, which is what the comparison below keeps asserting.
|
||||
realExpired = strings.ReplaceAll(realExpired, expiredTok, "<token>")
|
||||
unknown = strings.ReplaceAll(unknown, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "<token>")
|
||||
if realExpired != unknown {
|
||||
t.Errorf("an expired REAL token and an unknown token render differently — the page is an "+
|
||||
"oracle for whether a link existed.\n--- real ---\n%s\n--- unknown ---\n%s", realExpired, unknown)
|
||||
|
||||
@@ -97,7 +97,7 @@ func bindClientIP(r *http.Request) string {
|
||||
// --- the page ---
|
||||
|
||||
type bindPageData struct {
|
||||
State string // "form" | "success" | "expired" | "consumed" | "locked"
|
||||
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent"
|
||||
Token string // echoed into the form action (the capability itself; already in the URL)
|
||||
Failed bool // generic factor-check failure (form state only)
|
||||
// Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing
|
||||
@@ -157,6 +157,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(r.URL.Path, "/bind/")
|
||||
// R-719 (v0.126.0): „Új linket kérek" on an expired or used link.
|
||||
if t, ok := strings.CutSuffix(token, "/resend"); ok && r.Method == http.MethodPost && t != "" && !strings.Contains(t, "/") {
|
||||
s.handleBindResend(w, t)
|
||||
return
|
||||
}
|
||||
// A trailing segment only — reject anything with further path structure (defence in depth atop
|
||||
// the ServeMux path-clean; the token is a flat hex string).
|
||||
if token == "" || strings.Contains(token, "/") {
|
||||
@@ -194,10 +199,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
|
||||
// (nil) is folded into "expired": no oracle for "was this link ever real".
|
||||
switch {
|
||||
case tok == nil || tok.Expired(now):
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default})
|
||||
// The token is echoed for the resend form whether or not it is real: the page must not differ.
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default, Token: token})
|
||||
return
|
||||
case tok.Consumed():
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang})
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang, Token: token})
|
||||
return
|
||||
case tok.Locked:
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang})
|
||||
@@ -327,15 +333,60 @@ const bindPageHTML = `<!DOCTYPE html>
|
||||
{{else if eq .State "consumed"}}
|
||||
<p class="lead">{{T "bind.consumed.lead"}}</p>
|
||||
<p>{{T "bind.consumed.body"}}</p>
|
||||
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
|
||||
{{else if eq .State "resent"}}
|
||||
<p class="lead">{{T "bind.resent.lead"}}</p>
|
||||
<p>{{T "bind.resent.body"}}</p>
|
||||
{{else if eq .State "locked"}}
|
||||
<p class="lead">{{T "bind.locked.lead"}}</p>
|
||||
<p>{{T "bind.locked.body"}}</p>
|
||||
{{else}}
|
||||
<p class="lead">{{T "bind.invalid.lead"}}</p>
|
||||
<p>{{T "bind.invalid.body"}}</p>
|
||||
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="foot">Felhom.eu</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// ── R-719 (v0.126.0): a returning customer asks for a fresh link from the old one ─────────────────────
|
||||
//
|
||||
// A box that registers is UNCLAIMED — the hub cannot know whose it is until the bind (measured 2026-09-30:
|
||||
// the registration carries uuid, MACs, host keys and hardware, nothing of a customer). So "send the link
|
||||
// when their box registers" cannot be built without mailing every waiting customer. What the returning
|
||||
// customer DOES have is their old mail: its link now answers „expired" (7-day TTL) or „already used". That
|
||||
// page offers one press; the hub mints and mails a fresh link to the address REGISTERED for that link's
|
||||
// customer, and only when the customer has no box (the same guard as every other auto-send).
|
||||
//
|
||||
// No oracle: the answer is the same „resent" page, in the default language, whether the token was real,
|
||||
// unknown, still live, or the customer already has a box. Limits: the per-IP bind limiter, and one mail
|
||||
// per customer per bindResendEvery. Pinned by internal/web/selfbind_resend_test.go.
|
||||
|
||||
const bindResendEvery = time.Hour
|
||||
|
||||
func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
|
||||
defer s.renderBind(w, http.StatusOK, bindPageData{State: "resent", Lang: i18n.Default})
|
||||
tok, err := s.store.SelfBindTokenByHash(selfBindHash(token))
|
||||
if err != nil || tok == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if !tok.Expired(now) && !tok.Consumed() {
|
||||
return // a live link needs no replacement
|
||||
}
|
||||
s.bindResendMu.Lock()
|
||||
last := s.bindResendAt[tok.CustomerID]
|
||||
if now.Sub(last) < bindResendEvery {
|
||||
s.bindResendMu.Unlock()
|
||||
s.logger.Printf("[INFO] self-bind: fresh link for %s NOT sent — one was sent %s ago (limit %s)", tok.CustomerID, now.Sub(last).Round(time.Second), bindResendEvery)
|
||||
return
|
||||
}
|
||||
if s.bindResendAt == nil {
|
||||
s.bindResendAt = map[string]time.Time{}
|
||||
}
|
||||
s.bindResendAt[tok.CustomerID] = now
|
||||
s.bindResendMu.Unlock()
|
||||
s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
func resendPOST(s *Server, token string) *httptest.ResponseRecorder {
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleBind(rr, httptest.NewRequest("POST", "/bind/"+token+"/resend", nil))
|
||||
return rr
|
||||
}
|
||||
|
||||
// R-719 (v0.126.0) — a returning customer's old link has expired; the page offers a fresh one.
|
||||
// The CONSEQUENCE asserted: a NEW link is mailed to the registered address, and it binds (a live token).
|
||||
// COMPANION RED-PROOF: make handleBindResend return before autoMintSelfBindIfWaiting → no link mailed.
|
||||
func TestR719_AnExpiredLinkOffersAndSendsAFreshOne(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
m := &stubMailer{}
|
||||
s.SetSelfBindMailer(m)
|
||||
seedForMint(t, st, "tester", "tester1@felhom.example")
|
||||
old := mintLink(t, st, "tester", -time.Hour) // expired a while ago
|
||||
|
||||
if body := bindGET(t, s, old).Body.String(); !strings.Contains(body, "/bind/"+old+"/resend") || !strings.Contains(body, "Új linket kérek") {
|
||||
t.Fatal("the expired page does not offer a fresh link")
|
||||
}
|
||||
rr := resendPOST(s, old)
|
||||
if !strings.Contains(rr.Body.String(), "Kész.") {
|
||||
t.Fatalf("resend page: %s", rr.Body.String())
|
||||
}
|
||||
if m.link == "" {
|
||||
t.Fatal("no fresh link was mailed for an expired link of a box-less customer")
|
||||
}
|
||||
fresh := m.link[strings.LastIndexByte(m.link, '/')+1:]
|
||||
if tok, _ := st.SelfBindTokenByHash(selfBindHash(fresh)); tok == nil || tok.Expired(time.Now()) {
|
||||
t.Fatal("the mailed link is not live")
|
||||
}
|
||||
}
|
||||
|
||||
// No oracle, and no spam: an unknown token, a customer that already has a box, and a second press within
|
||||
// the hour all get the SAME page and NO mail.
|
||||
func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
m := &stubMailer{}
|
||||
s.SetSelfBindMailer(m)
|
||||
seedForMint(t, st, "tester", "tester1@felhom.example")
|
||||
old := mintLink(t, st, "tester", -time.Hour)
|
||||
|
||||
unknown := resendPOST(s, strings.Repeat("a", 64)).Body.String()
|
||||
if m.link != "" {
|
||||
t.Fatal("an unknown token mailed someone")
|
||||
}
|
||||
_ = resendPOST(s, old) // first press → mail
|
||||
first := m.link
|
||||
m.link = ""
|
||||
again := resendPOST(s, old).Body.String()
|
||||
if m.link != "" {
|
||||
t.Fatal("a second press within the hour mailed again")
|
||||
}
|
||||
if first == "" || unknown != again {
|
||||
t.Fatal("the answer differs between an unknown token and a real one — an oracle")
|
||||
}
|
||||
|
||||
// A customer that already has a box gets no link either.
|
||||
seedForMint(t, st, "boxed", "b@felhom.example")
|
||||
if err := st.UpsertHost(&store.Host{HostID: "boxed-1", CustomerID: "boxed", APIKey: "hk"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := mintLink(t, st, "boxed", -time.Hour)
|
||||
_ = resendPOST(s, b)
|
||||
if m.link != "" {
|
||||
t.Fatal("a customer with a box was mailed a bind link")
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ type Server struct {
|
||||
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
|
||||
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
|
||||
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
|
||||
bindResendMu sync.Mutex // R-719: the fresh-link resend limiter
|
||||
bindResendAt map[string]time.Time // customer → last fresh-link mail (R-719)
|
||||
// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
|
||||
// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
|
||||
// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =
|
||||
|
||||
Reference in New Issue
Block a user