hub v0.163.0: a rebuilt box gets the household's sealed package back (R-1176); one run per daily job on clock-change days (R-1183); a scoped abandonment purge (R-1182); the new-link page says the old box is still linked (R-1203)
gates / gates (push) Successful in 13m23s
gates / gates (push) Successful in 13m23s
This commit is contained in:
1 parent
e07b077ac2
commit
9688e76efd
16 files changed
+1018
-29
No files matched your search
@@ -2,6 +2,8 @@ package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -97,7 +99,7 @@ func bindClientIP(r *http.Request) string {
|
||||
// --- the page ---
|
||||
|
||||
type bindPageData struct {
|
||||
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent"
|
||||
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent" | "oldbox" (R-1203)
|
||||
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
|
||||
@@ -334,6 +336,9 @@ const bindPageHTML = `<!DOCTYPE html>
|
||||
<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 "oldbox"}}
|
||||
<p class="lead">{{T "bind.oldbox.lead"}}</p>
|
||||
<p>{{T "bind.oldbox.body"}}</p>
|
||||
{{else if eq .State "resent"}}
|
||||
<p class="lead">{{T "bind.resent.lead"}}</p>
|
||||
<p>{{T "bind.resent.body"}}</p>
|
||||
@@ -368,7 +373,8 @@ const bindPageHTML = `<!DOCTYPE html>
|
||||
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})
|
||||
page := bindPageData{State: "resent", Lang: i18n.Default}
|
||||
defer func() { s.renderBind(w, http.StatusOK, page) }()
|
||||
tok, err := s.store.SelfBindTokenByHash(selfBindHash(token))
|
||||
if err != nil || tok == nil {
|
||||
return
|
||||
@@ -377,6 +383,35 @@ func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
|
||||
if !tok.Expired(now) && !tok.Consumed() {
|
||||
return // a live link needs no replacement
|
||||
}
|
||||
// R-1203 (2026-10-21): a household whose old box is still linked waited for a mail that never came (rehearsal
|
||||
// 2026-10-20 B7, 17 minutes, then a phone call). The link holder is told so in plain words, and the operator gets
|
||||
// the host name to delete. This answers only a REAL used or expired link — an unknown token still gets the same
|
||||
// „resent" page, so a stranger learns nothing; the holder of a real link is the household (it came to their mailbox).
|
||||
if h, herr := s.store.GetHostByCustomer(tok.CustomerID); herr == nil && h != nil {
|
||||
page = bindPageData{State: "oldbox", Lang: s.store.CustomerLanguage(tok.CustomerID)}
|
||||
s.bindResendMu.Lock()
|
||||
key := "oldbox:" + tok.CustomerID
|
||||
last := s.bindResendAt[key]
|
||||
if now.Sub(last) < bindResendEvery {
|
||||
s.bindResendMu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.bindResendAt == nil {
|
||||
s.bindResendAt = map[string]time.Time{}
|
||||
}
|
||||
s.bindResendAt[key] = now
|
||||
s.bindResendMu.Unlock()
|
||||
s.logger.Printf("[WARN] self-bind: %s asked for a new link but host %s is still linked — no link sent; the operator is told", tok.CustomerID, h.HostID)
|
||||
if s.emit != nil {
|
||||
details, _ := json.Marshal(map[string]any{"host_id": h.HostID})
|
||||
s.emit(tok.CustomerID, eventBindOldHostLinked, "warning",
|
||||
fmt.Sprintf("Customer %s asked for a new box link, but host %s is still linked to it, so no link was sent. "+
|
||||
"If the household's old box is gone (a rebuild), delete host %s on the Hosts page, then send a new link from the customer page. "+
|
||||
"The household was told the old box is still linked and to write to Felhom.", tok.CustomerID, h.HostID, h.HostID),
|
||||
string(details), "hub")
|
||||
}
|
||||
return
|
||||
}
|
||||
s.bindResendMu.Lock()
|
||||
last := s.bindResendAt[tok.CustomerID]
|
||||
if now.Sub(last) < bindResendEvery {
|
||||
@@ -391,3 +426,6 @@ func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
|
||||
s.bindResendMu.Unlock()
|
||||
s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link")
|
||||
}
|
||||
|
||||
// eventBindOldHostLinked (R-1203): operator-only, registered in notify.operatorOnlyEvents in the same commit.
|
||||
const eventBindOldHostLinked = "bind_old_host_linked"
|
||||
@@ -41,8 +41,8 @@ func TestR719_AnExpiredLinkOffersAndSendsAFreshOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// No oracle, and no spam: an unknown token and a second press within the hour get the SAME page and NO mail; a
|
||||
// customer that already has a box gets no link (R-1203 below: its REAL link's holder is told why).
|
||||
func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
m := &stubMailer{}
|
||||
@@ -76,3 +76,39 @@ func TestR719_ResendIsNoOracleAndRateLimited(t *testing.T) {
|
||||
t.Fatal("a customer with a box was mailed a bind link")
|
||||
}
|
||||
}
|
||||
|
||||
// R-1203 (rehearsal 2026-10-20 B7): the household's old box is still linked. The holder of the household's REAL used
|
||||
// link is told so in plain words (not „a mail comes in minutes"), the operator is told the host name once per hour,
|
||||
// and no link is mailed. An unknown token still gets the generic page.
|
||||
// COMPANION RED-PROOF: drop the GetHostByCustomer branch in handleBindResend → the page says „Kész." and no event.
|
||||
func TestR1203_AHouseholdWhoseOldBoxIsStillLinkedIsToldSo(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
m := &stubMailer{}
|
||||
s.SetSelfBindMailer(m)
|
||||
type ev struct{ cust, typ, sev, msg, details string }
|
||||
var evs []ev
|
||||
s.emit = func(c, typ, sev, msg, details, src string) { evs = append(evs, ev{c, typ, sev, msg, details}) }
|
||||
seedForMint(t, st, "Tester4", "tester4@felhom.example")
|
||||
if err := st.UpsertHost(&store.Host{HostID: "Tester4-816e21", CustomerID: "Tester4", APIKey: "hk"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
used := mintLink(t, st, "Tester4", -time.Hour)
|
||||
|
||||
body := resendPOST(s, used).Body.String()
|
||||
if !strings.Contains(body, "A régi dobozod még össze van kötve") || strings.Contains(body, "percen belül új e-mailt") {
|
||||
t.Fatalf("the page does not say the old box is still linked:\n%s", body)
|
||||
}
|
||||
if m.link != "" {
|
||||
t.Fatal("a link was mailed while the old box is linked")
|
||||
}
|
||||
if len(evs) != 1 || evs[0].typ != "bind_old_host_linked" || evs[0].sev != "warning" || !strings.Contains(evs[0].msg, "Tester4-816e21") {
|
||||
t.Fatalf("operator events = %+v, want one warning naming the host", evs)
|
||||
}
|
||||
_ = resendPOST(s, used) // a second press within the hour: same page, no second mail
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("operator events after a second press = %d, want 1", len(evs))
|
||||
}
|
||||
if unk := resendPOST(s, strings.Repeat("b", 64)).Body.String(); strings.Contains(unk, "A régi dobozod") {
|
||||
t.Fatal("an unknown token learns that a box is linked")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user