hub(R-1299): the one-time clean-up window gets a button on the customer page
gates / gates (push) Successful in 1m51s
gates / gates (push) Successful in 1m51s
The Backup card's off-site part shows the window state (weekly switch, the normal cap, a waiting grant, the last window) and a form: optional limit -> a confirm step that names the customer and the limit -> the SAME route and rule as the script address (POST /offsite/window-grant/<customer>, wr(grpOffsite), the page's CSRF). A waiting grant can be cancelled. A raised limit above the store's measured snapshot count is refused (the 500 ceiling stays). Every grant and cancel: a log line and an operator-only info event offsite_window_grant (who, customer, limit). Tests TestR1299_* (five), red-proved by five mutations.
This commit is contained in:
1 parent
7610f69e7c
commit
d85198b835
8 files changed
+549
-32
No files matched your search
@@ -1,3 +1,22 @@
|
||||
## Unreleased (next: v0.174.0) — R-1299: the one-time clean-up window gets a button on the customer page
|
||||
|
||||
- **R-1299:** the customer page's Backup card, in its off-site part, shows a „Clean-up window" block: the weekly
|
||||
switch, the normal cap per window (half the snapshots, at least 5 — `offsitekeys.MaxRemove`), a waiting one-time
|
||||
grant and its limit, and the last window (`#id`, time, result, count before → after, cap). A form takes an optional
|
||||
limit; a confirm step (a GET that stores nothing) names the customer and the limit; the confirm posts to the SAME
|
||||
route as before, `POST /offsite/window-grant/<customer>` (rule unchanged: `wr(grpOffsite)` — a session or an
|
||||
offsite:write token), with the page's CSRF token, `from=page` and `confirm=1`, and answers with a redirect and a
|
||||
flash. A waiting grant shows a „Cancel" button instead (`cancel=1`, `store.CancelOffsiteWindowGrant`).
|
||||
- **New bound:** a raised limit above the store's own snapshot count (when the box has measured it) is refused — a
|
||||
window cannot remove more than exist, so a larger number is a typo; the 500 ceiling stays (`windowGrantBound`). The
|
||||
script path gets the same bound (400).
|
||||
- Every grant and cancel: a log line (customer, limit, who — the token's name or „operator session") and an
|
||||
operator-only info event `offsite_window_grant` (recorded, never mailed; `notify.operatorOnlyEvents`). The raised-cap
|
||||
grant still sends its warning `offsite_window_large_grant` as well. An unknown customer is refused on both paths.
|
||||
- `internal/web/offsite_window.go` (the handler moved out of `server.go`), `store.PendingOffsiteWindowGrant` (reads
|
||||
without consuming), `store.LatestOffsiteWindow`. Tests `TestR1299_*` (five), red-proved by five mutations (the block
|
||||
removed, the limit ignored, no bound, no confirm check, no cancel).
|
||||
|
||||
## v0.173.0 (2026-10-27) — D53: a held app reaches the operator (`08` §10); R-1218: a keyed recovery package is never replaced by a key-less one — DEPLOYED on the operator's yes (image sha256:458bfb03…, from `29134c52`, CI 2418)
|
||||
|
||||
- **R-972: Go 1.27.3** (`go.mod` + the builder image `golang:1.27.3-*` pinned by digest; released 2026-10-19 with a
|
||||
|
||||
@@ -802,6 +802,9 @@ var operatorOnlyEvents = map[string]bool{
|
||||
"offsite_prune_guard_refused": true,
|
||||
// R-833 (v0.129.0): the operator raised ONE window's removal cap — an operator act, logged.
|
||||
"offsite_window_large_grant": true,
|
||||
// R-1299 (v0.174.0): every grant and cancel of the one-shot window, with who and which limit — an operator act,
|
||||
// recorded (info), never mailed. Hub-origin only, like the large grant.
|
||||
"offsite_window_grant": true,
|
||||
// R-895 slice 2 (v0.158.0): the hub's own snapshot listing — files gone outside a clean-up window, files no box run
|
||||
// explains. Custody facts about a repository the household cannot inspect; listed in the SAME commit that mints
|
||||
// them. Hub-origin only: deliberately NOT in api.allowedEventTypes, so no box can POST one.
|
||||
|
||||
@@ -125,6 +125,20 @@ func (s *Store) GetOffsiteWindow(id int64) (*OffsiteWindow, error) {
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// LatestOffsiteWindow returns the customer's most recent window row, or (nil, nil) when there is none (R-1299: the
|
||||
// customer page shows its result next to the button).
|
||||
func (s *Store) LatestOffsiteWindow(customerID string) (*OffsiteWindow, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRow(`SELECT id FROM offsite_windows WHERE customer_id = ? ORDER BY id DESC LIMIT 1`, customerID).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetOffsiteWindow(id)
|
||||
}
|
||||
|
||||
// LastOffsiteWindowOpened returns when the customer's most recent window was opened (zero = never).
|
||||
func (s *Store) LastOffsiteWindowOpened(customerID string) time.Time {
|
||||
var v sql.NullString
|
||||
@@ -203,6 +217,32 @@ func (s *Store) TakeOffsiteWindowGrant(customerID string) (granted bool, maxRemo
|
||||
return true, maxRemove
|
||||
}
|
||||
|
||||
// PendingOffsiteWindowGrant reads a one-shot grant WITHOUT consuming it (R-1299: the page says a grant is waiting).
|
||||
// maxRemove is the raised cap, 0 = the default cap.
|
||||
func (s *Store) PendingOffsiteWindowGrant(customerID string) (pending bool, maxRemove int) {
|
||||
v := s.getSetting("offsite_window_grant:" + customerID)
|
||||
switch {
|
||||
case v == "1":
|
||||
return true, 0
|
||||
case strings.HasPrefix(v, "max:"):
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(v, "max:"))
|
||||
if err != nil || n <= 0 {
|
||||
n = 0
|
||||
}
|
||||
return true, n
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// CancelOffsiteWindowGrant removes a waiting one-shot grant (R-1299: the operator's undo before the box uses it).
|
||||
// cancelled is false when none was waiting.
|
||||
func (s *Store) CancelOffsiteWindowGrant(customerID string) (cancelled bool, err error) {
|
||||
if pending, _ := s.PendingOffsiteWindowGrant(customerID); !pending {
|
||||
return false, nil
|
||||
}
|
||||
return true, s.setSetting("offsite_window_grant:"+customerID, "")
|
||||
}
|
||||
|
||||
// ForceOffsiteWindowDueForTest back-dates a window's closes_by. TEST-ONLY.
|
||||
func (s *Store) ForceOffsiteWindowDueForTest(id int64) error {
|
||||
_, err := s.db.Exec(`UPDATE offsite_windows SET closes_by = datetime('now', '-1 minute') WHERE id = ?`, id)
|
||||
|
||||
@@ -305,7 +305,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
// BackupCard (R-331) is resolved in Go, not in the template: the card must distinguish
|
||||
// "no data reported" from "measured empty" from "never measured", and a {{if}} chain over
|
||||
// Report's float64s cannot. See backup_card.go.
|
||||
BackupCard backupCardView
|
||||
BackupCard backupCardView
|
||||
// WindowCard (R-1299) is the one-shot clean-up window block inside the Backup card's off-site part.
|
||||
WindowCard windowCardView
|
||||
OverallStatus string
|
||||
HostCause string // v0.53.0 roll-up: "" or "host down|stale|pending: <id>"
|
||||
|
||||
@@ -486,6 +488,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
Customer: customer,
|
||||
Report: report,
|
||||
BackupCard: backupCard,
|
||||
WindowCard: s.buildWindowCard(customerID, backupCard, r.URL.Query()),
|
||||
OverallStatus: overallStatus,
|
||||
HostCause: hostCause,
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// The one-shot clean-up window on the customer page (R-1299). Until hub v0.174.0 the grant was an address only a
|
||||
// script could call (`POST /offsite/window-grant/<customer>`), so on 2026-10-26 the operator had to mint a one-purpose
|
||||
// token to grant demo-hp one window (D55). The customer page's Backup card now shows the window state and a form: an
|
||||
// optional limit, a confirm step that names the customer and the limit, then the same route under the same rule
|
||||
// (`wr(grpOffsite)` — a session, or a token with offsite:write), with the page's CSRF token. Pinned by
|
||||
// offsite_window_test.go.
|
||||
|
||||
// eventWindowGrant records every grant and every cancel: who, which customer, which limit. Hub-origin, operator-only
|
||||
// (notify.operatorOnlyEvents), severity info — recorded, never mailed. The raised-cap grant keeps its own warning
|
||||
// mail as well (offsitekeys.EventWindowLargeGrant).
|
||||
const eventWindowGrant = "offsite_window_grant"
|
||||
|
||||
// windowCardView is the customer page's clean-up window block.
|
||||
type windowCardView struct {
|
||||
WeeklyOn bool // the fleet-wide weekly window switch
|
||||
Pending bool // a one-shot grant waits for the box's next off-site run
|
||||
PendingMax int // its raised limit, 0 = the normal cap
|
||||
Last *store.OffsiteWindow // the latest window, nil = never
|
||||
CapKnown bool // the snapshot count is known, so the normal cap is
|
||||
NormalCap int // offsitekeys.MaxRemove(snapshots): half, at least 5
|
||||
Bound int // the highest limit the form accepts (windowGrantBound)
|
||||
// Confirm is the second step: the page was opened with ?window=confirm; ConfirmLimit is the limit to confirm ("" =
|
||||
// the normal cap), already checked — an invalid one renders the refusal instead (LimitError).
|
||||
Confirm bool
|
||||
ConfirmLimit string
|
||||
LimitError string
|
||||
}
|
||||
|
||||
// windowGrantBound is the highest raised limit the hub accepts for this customer: never above the store's own
|
||||
// snapshot count when the box has measured it (a window cannot remove more than exist, so a larger number is a typo),
|
||||
// and never above offsitekeys.MaxRemoveGrantCeiling. known=false: only the ceiling applies.
|
||||
func windowGrantBound(card backupCardView) (bound int, known bool) {
|
||||
bound = offsitekeys.MaxRemoveGrantCeiling
|
||||
if card.OffsiteReported && card.StatsKnown && card.Snapshots > 0 && card.Snapshots < bound {
|
||||
return card.Snapshots, true
|
||||
}
|
||||
return bound, false
|
||||
}
|
||||
|
||||
// checkWindowLimit parses an optional limit: "" = the normal cap (0). The error is the sentence the page shows.
|
||||
func checkWindowLimit(raw string, card backupCardView) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 {
|
||||
return 0, fmt.Errorf("the limit must be a whole number of 1 or more, or empty for the normal cap")
|
||||
}
|
||||
if bound, known := windowGrantBound(card); n > bound {
|
||||
if known {
|
||||
return 0, fmt.Errorf("the limit %d is above the store's %d snapshot(s) — a window cannot remove more than exist", n, bound)
|
||||
}
|
||||
return 0, fmt.Errorf("the limit %d is above the ceiling %d", n, bound)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Server) customerBackupCard(customerID string) backupCardView {
|
||||
c, _ := s.store.GetCustomer(customerID)
|
||||
if c == nil {
|
||||
return backupCardView{}
|
||||
}
|
||||
return reportBackupCard(c.ReportJSON)
|
||||
}
|
||||
|
||||
// buildWindowCard resolves the block for the page (the confirm step reads ?window=confirm&window_max=N).
|
||||
func (s *Server) buildWindowCard(customerID string, card backupCardView, q url.Values) windowCardView {
|
||||
v := windowCardView{WeeklyOn: s.store.OffsiteWindowsEnabled()}
|
||||
v.Pending, v.PendingMax = s.store.PendingOffsiteWindowGrant(customerID)
|
||||
v.Last, _ = s.store.LatestOffsiteWindow(customerID)
|
||||
if card.StatsKnown {
|
||||
v.CapKnown, v.NormalCap = true, offsitekeys.MaxRemove(card.Snapshots)
|
||||
}
|
||||
v.Bound, _ = windowGrantBound(card)
|
||||
if q.Get("window") == "confirm" {
|
||||
if _, err := checkWindowLimit(q.Get("window_max"), card); err != nil {
|
||||
v.LimitError = err.Error()
|
||||
} else {
|
||||
v.Confirm, v.ConfirmLimit = true, strings.TrimSpace(q.Get("window_max"))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// actorOf names who made a request: the token's name, or the operator's session.
|
||||
func actorOf(r *http.Request) string {
|
||||
if t := requestToken(r); t != nil {
|
||||
return "token " + t.Name
|
||||
}
|
||||
return "operator session"
|
||||
}
|
||||
|
||||
// handleOffsiteWindowGrant serves POST /offsite/window-grant/<customer> and POST /offsite/windows-enabled
|
||||
// (decision 68; R-833; R-1299). A script gets JSON as before; the page's form (from=page) gets a redirect with a flash.
|
||||
//
|
||||
// max_remove optional raised limit for this ONE window (bounded: windowGrantBound)
|
||||
// cancel=1 remove a waiting grant instead
|
||||
// confirm=1 required from the page: the form's second step
|
||||
func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if r.Method != http.MethodPost || s.offsiteWindowGrant == nil {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if path == "/offsite/windows-enabled" {
|
||||
if err := s.offsiteWindowSwitch(r.FormValue("on") == "1"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{\"ok\":true}\n"))
|
||||
return
|
||||
}
|
||||
cid := strings.TrimPrefix(path, "/offsite/window-grant/")
|
||||
page := r.FormValue("from") == "page"
|
||||
answer := func(code int, flash, msg string) {
|
||||
if page {
|
||||
http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#offsite-window", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if code >= 400 {
|
||||
http.Error(w, msg, code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{\"ok\":true}\n"))
|
||||
}
|
||||
if cfg, _ := s.store.GetCustomerConfig(cid); cfg == nil {
|
||||
answer(http.StatusNotFound, "window_unknown_customer", "no customer "+cid)
|
||||
return
|
||||
}
|
||||
who := actorOf(r)
|
||||
|
||||
if r.FormValue("cancel") == "1" {
|
||||
cancelled, err := s.store.CancelOffsiteWindowGrant(cid)
|
||||
if err != nil {
|
||||
answer(http.StatusInternalServerError, "window_failed", err.Error())
|
||||
return
|
||||
}
|
||||
if !cancelled {
|
||||
answer(http.StatusConflict, "window_none_waiting", "no grant is waiting for "+cid)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite window: the waiting grant for %s was CANCELLED by %s", cid, who)
|
||||
s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: the waiting one-time window was cancelled (by %s).", who), map[string]any{"cancelled": true, "by": who})
|
||||
answer(http.StatusOK, "window_cancelled", "")
|
||||
return
|
||||
}
|
||||
|
||||
if page && r.FormValue("confirm") != "1" {
|
||||
answer(http.StatusBadRequest, "window_needs_confirm", "confirm=1 required")
|
||||
return
|
||||
}
|
||||
limit, err := checkWindowLimit(r.FormValue("max_remove"), s.customerBackupCard(cid))
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] offsite window: grant for %s REFUSED (%v) — asked by %s", cid, err, who)
|
||||
answer(http.StatusBadRequest, "window_limit_refused", err.Error())
|
||||
return
|
||||
}
|
||||
if limit > 0 {
|
||||
if s.offsiteWindowGrantMax == nil {
|
||||
answer(http.StatusServiceUnavailable, "window_failed", "a raised limit is unavailable")
|
||||
return
|
||||
}
|
||||
err = s.offsiteWindowGrantMax(cid, limit)
|
||||
} else {
|
||||
err = s.offsiteWindowGrant(cid)
|
||||
}
|
||||
if err != nil {
|
||||
answer(http.StatusBadRequest, "window_failed", err.Error())
|
||||
return
|
||||
}
|
||||
lim := "the normal cap"
|
||||
if limit > 0 {
|
||||
lim = fmt.Sprintf("a limit of %d", limit)
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite window: ONE clean-up window granted for %s with %s, by %s", cid, lim, who)
|
||||
s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: one window granted with %s (by %s); the box uses it on its next off-site run.", lim, who),
|
||||
map[string]any{"max_remove": limit, "by": who})
|
||||
answer(http.StatusOK, "window_granted", "")
|
||||
}
|
||||
|
||||
func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) {
|
||||
if s.emit == nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(details)
|
||||
s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub")
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-1299: the one-shot clean-up window on the customer page. The page renders the block per gate branch; a grant
|
||||
// through the form (session + CSRF + the confirm step) stores ONE grant with its limit; the box's next window uses it
|
||||
// up; a limit above the store's snapshot count is refused; a POST without CSRF never reaches the store.
|
||||
|
||||
const windowReport = `{"customer_id":"acme","customer_name":"Acme","offsite":{"enabled":true,"state":"active",
|
||||
"last_status":"ok","last_success":"2026-10-27T03:17:00Z","snapshot_count":40,"stats_known":true}}`
|
||||
|
||||
type windowEvent struct{ customer, typ, sev, msg, details string }
|
||||
|
||||
// windowRig: a password-protected server (so RequireAuth and the CSRF gate run) with one customer whose box reported
|
||||
// 40 off-site snapshots, the grant functions wired as main.go wires them (the store's), and the events captured.
|
||||
func windowRig(t *testing.T, report string) (*Server, *store.Store, http.Handler, *[]windowEvent) {
|
||||
t.Helper()
|
||||
s, st := serverWithPassword(t, "op-pass")
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu",
|
||||
RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report != "" {
|
||||
if err := st.SaveReport("acme", []byte(report)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.SetOffsiteWindowAdmin(st.GrantOffsiteWindowOnce, st.SetOffsiteWindowsEnabled)
|
||||
s.SetOffsiteWindowLargeGrant(st.GrantOffsiteWindowOnceMax)
|
||||
var evs []windowEvent
|
||||
s.SetEventEmitter(func(c, typ, sev, msg, det, _ string) { evs = append(evs, windowEvent{c, typ, sev, msg, det}) })
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
|
||||
s.sessionsMu.Unlock()
|
||||
return s, st, s.Handler(), &evs
|
||||
}
|
||||
|
||||
func windowPost(h http.Handler, form url.Values, csrf bool) *httptest.ResponseRecorder {
|
||||
if csrf {
|
||||
form.Set("_csrf", "tok1")
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/offsite/window-grant/acme", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func windowPage(t *testing.T, h http.Handler, query string) string {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, "/customers/acme"+query, nil)
|
||||
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /customers/acme%s: %d", query, w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// The page, per branch of its gates (the seam-wiring rule: a handler test alone proves nothing about reachability).
|
||||
// RED-PROOF: remove the block from customer_unified.html → the first case fails; drop `{{if .Pending}}` → the third.
|
||||
func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) {
|
||||
_, st, h, _ := windowRig(t, windowReport)
|
||||
|
||||
page := windowPage(t, h, "")
|
||||
for _, want := range []string{`id="offsite-window"`, `id="window-form"`, `name="window" value="confirm"`, "Normal cap per window",
|
||||
"20 (half the snapshots", "at most 40", "none waiting", "never"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("the page lacks %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
page = windowPage(t, h, "?window=confirm&window_max=33")
|
||||
for _, want := range []string{`id="window-confirm"`, "a limit of 33", `name="confirm" value="1"`, `name="_csrf" value="tok1"`, `name="from" value="page"`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("the confirm step lacks %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(windowPage(t, h, "?window=confirm&window_max=41"), "above the store's 40 snapshot(s)") {
|
||||
t.Error("a limit above the snapshot count must render the refusal at the confirm step, not the confirm")
|
||||
}
|
||||
|
||||
if err := st.GrantOffsiteWindowOnceMax("acme", 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page = windowPage(t, h, "")
|
||||
if !strings.Contains(page, "limit 30") || !strings.Contains(page, `name="cancel" value="1"`) || strings.Contains(page, `id="window-form"`) {
|
||||
t.Error("a waiting grant must show its limit and the cancel button, and no second form")
|
||||
}
|
||||
|
||||
_, _, h2, _ := windowRig(t, `{"customer_id":"acme","offsite":{"enabled":false}}`)
|
||||
if strings.Contains(windowPage(t, h2, ""), `id="offsite-window"`) {
|
||||
t.Error("the block must not render when off-site is not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// The form's grant: ONE grant with the limit, a redirect with its flash, an operator event naming who and the limit;
|
||||
// the box's window uses it up, and the next window finds none.
|
||||
// RED-PROOF: make the handler ignore max_remove → the stored grant has no limit → fails.
|
||||
func TestR1299_AGrantThroughTheFormStoresOneGrantWithItsLimit(t *testing.T) {
|
||||
_, st, h, evs := windowRig(t, windowReport)
|
||||
w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"33"}}, true)
|
||||
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=window_granted") {
|
||||
t.Fatalf("grant: %d Location=%q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
if pending, max := st.PendingOffsiteWindowGrant("acme"); !pending || max != 33 {
|
||||
t.Fatalf("stored grant: pending=%v max=%d, want true 33", pending, max)
|
||||
}
|
||||
if len(*evs) != 1 || (*evs)[0].typ != eventWindowGrant || (*evs)[0].sev != "info" ||
|
||||
!strings.Contains((*evs)[0].details, `"max_remove":33`) || !strings.Contains((*evs)[0].details, `"by":"operator session"`) {
|
||||
t.Fatalf("events: %+v — want one info %s naming the limit and who", *evs, eventWindowGrant)
|
||||
}
|
||||
if granted, max := st.TakeOffsiteWindowGrant("acme"); !granted || max != 33 {
|
||||
t.Fatalf("the window took granted=%v max=%d", granted, max)
|
||||
}
|
||||
if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted {
|
||||
t.Fatal("a second window reused the one-shot grant")
|
||||
}
|
||||
if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
|
||||
t.Fatal("the page would still show a used grant as waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// Refusals that store nothing: a limit above the snapshot count, a non-number, no confirm step, no CSRF token.
|
||||
// RED-PROOF: drop the bound in checkWindowLimit → the 41 case stores a grant → fails; drop the confirm check → the
|
||||
// no-confirm case stores one.
|
||||
func TestR1299_RefusalsStoreNothing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form url.Values
|
||||
csrf bool
|
||||
want func(*httptest.ResponseRecorder) bool
|
||||
}{
|
||||
{"above the snapshot count", url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"41"}}, true,
|
||||
func(w *httptest.ResponseRecorder) bool {
|
||||
return strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused")
|
||||
}},
|
||||
{"not a number", url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"2e2"}}, true,
|
||||
func(w *httptest.ResponseRecorder) bool {
|
||||
return strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused")
|
||||
}},
|
||||
{"script above the snapshot count", url.Values{"max_remove": {"500"}}, true,
|
||||
func(w *httptest.ResponseRecorder) bool { return w.Code == http.StatusBadRequest }},
|
||||
{"no confirm step", url.Values{"from": {"page"}, "max_remove": {"10"}}, true,
|
||||
func(w *httptest.ResponseRecorder) bool {
|
||||
return strings.Contains(w.Header().Get("Location"), "flash=window_needs_confirm")
|
||||
}},
|
||||
{"no CSRF token", url.Values{"from": {"page"}, "confirm": {"1"}}, false,
|
||||
func(w *httptest.ResponseRecorder) bool { return w.Code == http.StatusForbidden }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, st, h, evs := windowRig(t, windowReport)
|
||||
w := windowPost(h, c.form, c.csrf)
|
||||
if !c.want(w) {
|
||||
t.Errorf("%s: %d Location=%q", c.name, w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
|
||||
t.Errorf("%s: a grant was stored", c.name)
|
||||
}
|
||||
if len(*evs) != 0 {
|
||||
t.Errorf("%s: %d event(s) for a refused request", c.name, len(*evs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a measured snapshot count only the ceiling bounds the limit (D55's 260 must stay possible).
|
||||
func TestR1299_WithoutACountOnlyTheCeilingBounds(t *testing.T) {
|
||||
_, st, h, _ := windowRig(t, "")
|
||||
if w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"260"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_granted") {
|
||||
t.Fatalf("260 with no count: Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if _, max := st.PendingOffsiteWindowGrant("acme"); max != 260 {
|
||||
t.Fatalf("stored max %d, want 260", max)
|
||||
}
|
||||
_, st2, h2, _ := windowRig(t, "")
|
||||
if w := windowPost(h2, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"501"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused") {
|
||||
t.Fatalf("501: Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if pending, _ := st2.PendingOffsiteWindowGrant("acme"); pending {
|
||||
t.Fatal("501 stored a grant")
|
||||
}
|
||||
}
|
||||
|
||||
// The cancel removes a waiting grant and records who; with none waiting it changes nothing.
|
||||
func TestR1299_CancelRemovesAWaitingGrant(t *testing.T) {
|
||||
_, st, h, evs := windowRig(t, windowReport)
|
||||
if w := windowPost(h, url.Values{"from": {"page"}, "cancel": {"1"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_none_waiting") {
|
||||
t.Fatalf("cancel with none waiting: Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
_ = st.GrantOffsiteWindowOnce("acme")
|
||||
if w := windowPost(h, url.Values{"from": {"page"}, "cancel": {"1"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_cancelled") {
|
||||
t.Fatalf("cancel: Location=%q", w.Header().Get("Location"))
|
||||
}
|
||||
if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
|
||||
t.Fatal("the grant is still waiting after the cancel")
|
||||
}
|
||||
if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted {
|
||||
t.Fatal("the box's window would still use the cancelled grant")
|
||||
}
|
||||
if len(*evs) != 1 || !strings.Contains((*evs)[0].details, `"cancelled":true`) {
|
||||
t.Fatalf("events: %+v — want one cancel event", *evs)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -777,36 +776,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]int{"changed": n})
|
||||
case strings.HasPrefix(path, "/offsite/window-grant/") || path == "/offsite/windows-enabled":
|
||||
// Operator (decision 68): a one-shot grant lets the customer's NEXT window request through;
|
||||
// the switch turns the WEEKLY window on/off fleet-wide (off = the interim: nothing prunes).
|
||||
if r.Method != http.MethodPost || s.offsiteWindowGrant == nil {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if path == "/offsite/windows-enabled" {
|
||||
err = s.offsiteWindowSwitch(r.FormValue("on") == "1")
|
||||
} else if mr := r.FormValue("max_remove"); mr != "" {
|
||||
// R-833: one window with a raised removal cap (operator only — this server is behind the
|
||||
// operator's login; the box API has no route to it).
|
||||
n, perr := strconv.Atoi(mr)
|
||||
if perr != nil || s.offsiteWindowGrantMax == nil {
|
||||
http.Error(w, "max_remove must be a number", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err = s.offsiteWindowGrantMax(strings.TrimPrefix(path, "/offsite/window-grant/"), n); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = s.offsiteWindowGrant(strings.TrimPrefix(path, "/offsite/window-grant/"))
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{\"ok\":true}\n"))
|
||||
// Operator (decision 68): a one-shot grant lets the customer's NEXT window request through, with an optional
|
||||
// raised cap (R-833); the switch turns the WEEKLY window on/off fleet-wide. The customer page's form (R-1299)
|
||||
// posts here too — offsite_window.go.
|
||||
s.handleOffsiteWindowGrant(w, r, path)
|
||||
case strings.HasPrefix(path, "/os/"):
|
||||
// Operator (hub v0.130.0, `11` §8 step 2): per-box ring and switch, approve now, the fleet lines.
|
||||
s.handleOSAdmin(w, r, path)
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
{{else if eq .Flash "selfbind-no-email"}}Self-bind link NOT sent — this customer has no registered email address. Set one first, or bind the appliance manually from the Hosts page.
|
||||
{{else if eq .Flash "selfbind-send-failed"}}Self-bind link send FAILED — the link was invalidated (not left live). Check the hub log (email delivery / send error).
|
||||
{{else if eq .Flash "reset_done"}}Customer RESET complete — every operational trace was destroyed (offsite repo, PBS namespace, DR recipe, claim state, retained escrow custody). Identity and basic config survive; the audit event stream records it.
|
||||
{{else if eq .Flash "window_granted"}}One clean-up window granted — the box uses it on its next off-site run (see „Clean-up window” in the Backup card).
|
||||
{{else if eq .Flash "window_cancelled"}}The waiting clean-up window was cancelled — nothing will be removed for it.
|
||||
{{else if eq .Flash "window_none_waiting"}}No clean-up window was waiting — nothing changed.
|
||||
{{else if eq .Flash "window_needs_confirm"}}Not granted — the clean-up window needs the confirm step. Nothing was saved.
|
||||
{{else if eq .Flash "window_limit_refused"}}Not granted — the limit is not a whole number of 1 or more, or it is above the store's snapshot count. Nothing was saved.
|
||||
{{else if eq .Flash "window_unknown_customer"}}Not granted — no such customer.
|
||||
{{else if eq .Flash "window_failed"}}The clean-up window could not be saved — see the hub log. Nothing was granted.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -337,6 +344,61 @@
|
||||
claim the repository is empty and that is not what is known. They fill in after the next
|
||||
off-site run reads the repository.</p>
|
||||
{{end}}
|
||||
|
||||
<!-- R-1299: the one-shot clean-up window. Same route and rule as the script address
|
||||
(POST /offsite/window-grant/<customer>, offsite:write); the confirm step is a GET that stores nothing. -->
|
||||
{{with $.WindowCard}}
|
||||
<h3 id="offsite-window">Clean-up window</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Weekly windows</span>
|
||||
<span class="value">{{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Normal cap per window</span>
|
||||
<span class="value">{{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}— (half the snapshots; count not measured){{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">One-time window</span>
|
||||
<span class="value">{{if .Pending}}<strong>waiting</strong> — {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Last window</span>
|
||||
<span class="value">{{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Pending}}
|
||||
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="from" value="page">
|
||||
<input type="hidden" name="cancel" value="1">
|
||||
<button type="submit" class="btn btn-outline btn-sm">Cancel the waiting window</button>
|
||||
</form>
|
||||
{{else if .Confirm}}
|
||||
<div class="flash flash-warn" id="window-confirm">
|
||||
Open <strong>one</strong> clean-up window for <strong>{{$.CustomerID}}</strong> with
|
||||
<strong>{{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}</strong>?
|
||||
The box uses it on its next off-site run; it removes only what its keep rule selects, and the
|
||||
fake-snapshot guard still runs. One use, then it is gone.
|
||||
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="from" value="page">
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<input type="hidden" name="max_remove" value="{{.ConfirmLimit}}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Open the window</button>
|
||||
<a href="/customers/{{$.CustomerID}}#offsite-window" class="btn btn-outline btn-sm">Back</a>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
{{if .LimitError}}<p class="flash flash-error">Not granted: {{.LimitError}}.</p>{{end}}
|
||||
<form method="GET" action="/customers/{{$.CustomerID}}" class="inline-confirm" id="window-form">
|
||||
<input type="hidden" name="window" value="confirm">
|
||||
<label for="window_max">Limit for this window (empty = the normal cap; at most {{.Bound}})</label>
|
||||
<input type="number" id="window_max" name="window_max" min="1" max="{{.Bound}}">
|
||||
<button type="submit" class="btn btn-outline btn-sm">Open one clean-up window…</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user