hub (unreleased): R-922 option A — a household's clear deletes its notification address (email_cleared); MAIL-HOLD — a restored hub sends no mail until released; two log lines drop the address; runbooks: mail hold is restore step 1; 07 §6.4 R-921 pre-check; R-921/R-922 narrowed
gates / gates (push) Successful in 5m25s
gates / gates (push) Successful in 5m25s
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:
@@ -50,6 +50,7 @@ func TestTemplatesParseWithFuncmap(t *testing.T) {
|
||||
"memoryColor": memoryColor,
|
||||
"accuracyClass": accuracyClass,
|
||||
"gt": func(a, b int) bool { return false },
|
||||
"mailHeld": func() bool { return false },
|
||||
}).ParseFS(templateFS, "templates/*.html"); err != nil {
|
||||
t.Fatalf("templates failed to parse: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
|
||||
)
|
||||
|
||||
// MAIL-HOLD on the operator UI: the banner on every operator page while the marker exists, and the release endpoint
|
||||
// behind the operator's auth + the R-135 CSRF gate.
|
||||
|
||||
const mailHoldBannerText = "E-mail is on hold"
|
||||
|
||||
func holdIn(t *testing.T, held bool) (*mailhold.Hold, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, mailhold.FileName)
|
||||
if held {
|
||||
if err := os.WriteFile(marker, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return mailhold.New(dir, nil), marker
|
||||
}
|
||||
|
||||
func getPage(t *testing.T, s *Server, path string) string {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s: %d", path, w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// Both branches of the {{if mailHeld}} gate, on the dashboard and on Configuration (which also carries the button).
|
||||
func TestMailHoldBanner_RendersOnlyWhileHeld(t *testing.T) {
|
||||
for _, held := range []bool{true, false} {
|
||||
s, _ := newTestServer(t)
|
||||
h, _ := holdIn(t, held)
|
||||
s.SetMailHold(h)
|
||||
for _, p := range []string{"/", "/configuration", "/hosts", "/configs"} {
|
||||
body := getPage(t, s, p)
|
||||
if got := strings.Contains(body, mailHoldBannerText); got != held {
|
||||
t.Errorf("held=%v GET %s: banner shown=%v", held, p, got)
|
||||
}
|
||||
}
|
||||
cfg := getPage(t, s, "/configuration")
|
||||
if got := strings.Contains(cfg, `action="/configuration/mail-hold/release"`); got != held {
|
||||
t.Errorf("held=%v: release button shown=%v", held, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No hold wired at all (nil): no banner, pages render.
|
||||
func TestMailHoldBanner_NilHoldRendersNoBanner(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
if strings.Contains(getPage(t, s, "/"), mailHoldBannerText) {
|
||||
t.Fatal("a nil hold must render no banner")
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's release, through the production wiring (RequireAuth around ServeHTTP) with a session and its CSRF
|
||||
// token: the marker is gone, and the banner with it.
|
||||
func TestMailHoldRelease_RemovesMarker(t *testing.T) {
|
||||
s, h := r135Handler(t)
|
||||
hold, marker := holdIn(t, true)
|
||||
s.SetMailHold(hold)
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
w := r135Post(h, "/configuration/mail-hold/release", func(r *http.Request) {
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"})
|
||||
r.Header.Set("X-CSRF-Token", "tok1")
|
||||
})
|
||||
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=mail_hold_released") {
|
||||
t.Fatalf("release: %d Location=%q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("the marker is still there after the release: %v", err)
|
||||
}
|
||||
if hold.Held() {
|
||||
t.Fatal("still held after the release")
|
||||
}
|
||||
}
|
||||
|
||||
// Refusals: Basic auth without the operator header (the R-135 cross-site shape), a session without its token, and no
|
||||
// credentials at all. The marker stays in every case.
|
||||
func TestMailHoldRelease_RefusedWithoutOperatorAuthOrCSRF(t *testing.T) {
|
||||
s, h := r135Handler(t)
|
||||
hold, marker := holdIn(t, true)
|
||||
s.SetMailHold(hold)
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*http.Request)
|
||||
ok func(int) bool
|
||||
}{
|
||||
{"basic without operator header", func(r *http.Request) { r.SetBasicAuth("", "op-pass") }, func(c int) bool { return c == http.StatusForbidden }},
|
||||
{"session without token", func(r *http.Request) { r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) }, func(c int) bool { return c == http.StatusForbidden }},
|
||||
{"no credentials", func(r *http.Request) { r.Header.Set(OperatorCLIHeader, "cli") }, func(c int) bool { return c == http.StatusFound || c == http.StatusUnauthorized }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w := r135Post(h, "/configuration/mail-hold/release", c.mut)
|
||||
if !c.ok(w.Code) {
|
||||
t.Errorf("%s: status %d", c.name, w.Code)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("%s: the marker was removed by a refused request: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The CLI shape (Basic + the operator header) passes.
|
||||
w := r135Post(h, "/configuration/mail-hold/release", func(r *http.Request) {
|
||||
r.SetBasicAuth("", "op-pass")
|
||||
r.Header.Set(OperatorCLIHeader, "cli")
|
||||
})
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("operator CLI release: %d", w.Code)
|
||||
}
|
||||
if hold.Held() {
|
||||
t.Fatal("operator CLI release left the hold in place")
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ var r135PostRoutes = []string{
|
||||
"/configuration/global-floor",
|
||||
"/configuration/artifacts",
|
||||
"/configuration/password",
|
||||
"/configuration/mail-hold/release",
|
||||
"/configs/c1/delete",
|
||||
"/configs/c1/edit",
|
||||
"/configs/c1/offsite-reissue",
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
|
||||
@@ -124,13 +125,20 @@ type Server struct {
|
||||
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
|
||||
// save. nil in production.
|
||||
beforeCreateSave func(customerID string)
|
||||
// mailHold is the hub-wide mail gate (internal/mailhold); nil never holds.
|
||||
mailHold *mailhold.Hold
|
||||
// cfAPIBase is a TEST seam for the Cloudflare token reach check (R-138 option C): "" = the real API.
|
||||
cfAPIBase string
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
||||
// srv is assigned below; template funcs that read server state close over it (they run at render time only).
|
||||
var srv *Server
|
||||
funcMap := template.FuncMap{
|
||||
// mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the
|
||||
// banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*.
|
||||
"mailHeld": func() bool { return srv != nil && srv.mailHold.Held() },
|
||||
"timeAgo": timeAgo,
|
||||
"timeAgoPtr": func(t *time.Time) string {
|
||||
if t == nil {
|
||||
@@ -160,7 +168,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
|
||||
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
|
||||
|
||||
return &Server{
|
||||
srv = &Server{
|
||||
store: store,
|
||||
configPasswordHash: passwordHash,
|
||||
apiKey: apiKey,
|
||||
@@ -171,6 +179,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
sessions: make(map[string]*hubSession),
|
||||
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB
|
||||
@@ -406,6 +415,29 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
|
||||
}
|
||||
|
||||
// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY.
|
||||
// SetMailHold wires the hub-wide mail gate (internal/mailhold): the banner on every operator page and the release
|
||||
// endpoint. nil = never held (no banner, the release is a no-op).
|
||||
func (s *Server) SetMailHold(m *mailhold.Hold) {
|
||||
s.mailHold = m
|
||||
}
|
||||
|
||||
// handleMailHoldRelease lifts the MAIL-HOLD: it removes the marker, and the hub sends e-mail again from the next mail
|
||||
// on. Mails dropped during the hold are NOT re-sent (internal/mailhold explains why). Operator-only: it sits behind
|
||||
// RequireAuth and the R-135 CSRF gate in ServeHTTP like every other state-changing route.
|
||||
func (s *Server) handleMailHoldRelease(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mailHold == nil || !s.mailHold.Held() {
|
||||
http.Redirect(w, r, "/configuration?flash=mail_hold_not_held", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.mailHold.Release(); err != nil {
|
||||
s.logger.Printf("[ERROR] MAIL-HOLD: release failed — the marker is still in place: %v", err)
|
||||
http.Redirect(w, r, "/configuration?flash=mail_hold_release_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] MAIL-HOLD released by the operator from %s — the hub sends e-mail again; mail held until now was dropped", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/configuration?flash=mail_hold_released", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) {
|
||||
s.emit = f
|
||||
}
|
||||
@@ -668,6 +700,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configuration/mail-hold/release":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleMailHoldRelease(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configuration/password":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleChangePassword(w, r)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">← Apps</a>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">App Telemetry</h2>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="back-link">← Back</a>
|
||||
<h2>{{if .IsNew}}Add Customer{{else}}Edit: {{.Config.CustomerID}}{{end}}</h2>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-success">
|
||||
|
||||
@@ -21,9 +21,26 @@
|
||||
<a href="/configuration" class="nav-link active">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">Configuration</h2>
|
||||
|
||||
{{if mailHeld}}
|
||||
<form method="POST" action="/configuration/mail-hold/release" class="mail-hold-release">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn">Release the mail hold</button>
|
||||
<span class="text-muted">Removes the marker. The hub sends e-mail again from the next mail on; mail held until now is not re-sent.</span>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if eq .Flash "mail_hold_released"}}
|
||||
<div class="flash flash-success">Mail hold released — the hub sends e-mail again. Mail held until now was not re-sent.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "mail_hold_release_failed"}}
|
||||
<div class="flash flash-error">The mail hold could not be released — the marker is still in place. Check the hub log.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "mail_hold_not_held"}}
|
||||
<div class="flash flash-success">The mail hold was not on — nothing to release.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "assets_refreshed"}}
|
||||
<div class="flash flash-success">Assets refreshed successfully from image seed.</div>
|
||||
{{end}}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<p class="subtitle">No reports received yet</p>
|
||||
{{end}}
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-success">
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
{{if or .OffsiteTile .PBSTile}}
|
||||
<div class="offsite-gauges">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<a href="/hosts" class="back-link">← Hosts</a>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">Hosts</h2>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<a href="/customers/{{.CustomerID}}" class="back-link">← {{.CustomerID}}</a>
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "mail_hold_banner"}}{{if mailHeld}}
|
||||
<div class="flash flash-warn" id="mail-hold" role="alert">
|
||||
<strong>E-mail is on hold</strong> — this hub sends no e-mail at all, to households or to you, while the
|
||||
<code>MAIL-HOLD</code> marker is in its data directory. Mail it would have sent is dropped, not queued.
|
||||
Release the hold on the <a href="/configuration#mail-hold">Configuration</a> page once this is the only hub
|
||||
that is running.
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
@@ -21,6 +21,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<h2 style="margin-bottom: 0.75rem;">Offsite</h2>
|
||||
|
||||
|
||||
@@ -1020,3 +1020,14 @@ body.js-tabs .tab-panel:not(.tab-panel-active) { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* MAIL-HOLD release (internal/mailhold): the button and its one-line explanation sit on one row under the banner. */
|
||||
.mail-hold-release {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mail-hold-release .text-muted { font-size: 0.8rem; }
|
||||
#mail-hold a { color: var(--text-1); }
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "mail_hold_banner"}}
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">System — versions and OS updates</h2>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user