48f3336956
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
259 lines
11 KiB
Go
259 lines
11 KiB
Go
package backup
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
)
|
|
|
|
// the real demo-hp target shape — the values the sanitiser must remove literally
|
|
func diagTarget() *settings.OffboxTarget {
|
|
return &settings.OffboxTarget{
|
|
Host: "u629488-sub3.your-storagebox.de", User: "u629488-sub3",
|
|
RepoPath: "/home/felhom-repo", Port: 23,
|
|
}
|
|
}
|
|
|
|
// F-DIAG — four causes collapsed into one string, and that string was a RAW error passthrough.
|
|
//
|
|
// Two separate defects in one line of code:
|
|
// - an operator could not tell a full quota from a dead network without reading logs;
|
|
// - `err.Error()` from restic/ssh carries the repo reference `sftp:<user>@<host>:<path>`, so the
|
|
// notification carried a customer-identifying location (and potentially a credential) off the box,
|
|
// breaking the keys-not-values rule at the one place the text leaves the machine.
|
|
|
|
func TestClassifyOffsiteFailure_EachCauseIsDistinct(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want OffsiteFailureClass
|
|
}{
|
|
// R-553 (v0.251.0): the quota refusal is built with its KIND at the producer, exactly as the run
|
|
// builds it — the classifier no longer recognises this sentence by its Hungarian words, and that
|
|
// is the point (localisation slice 2 translates them).
|
|
{"quota gate", util.KindErrorf(ErrOffsiteQuota, "A távoli mentés túllépte a tárhelykeretet (51/50 GB) — törölj régi mentéseket vagy kérj nagyobb keretet."), OffsiteFailQuota},
|
|
{"orphaned repo", fmt.Errorf("probe: %w", ErrOffboxOrphaned), OffsiteFailOrphaned},
|
|
{"no repo", fmt.Errorf("restic: unable to open config file: Stat: file does not exist\nIs there a repository at the following location?"), OffsiteFailNoRepo},
|
|
{"no units", fmt.Errorf("off-box backup produced no snapshots: 3 app(s) toggled but no recovery unit was found on any connected drive (missing: a, b, c)"), OffsiteFailNoUnits},
|
|
{"transport refused", fmt.Errorf("dial tcp 1.2.3.4:23: connect: connection refused"), OffsiteFailTransport},
|
|
{"transport timeout", fmt.Errorf("ssh: handshake failed: i/o timeout"), OffsiteFailTransport},
|
|
{"transport auth", fmt.Errorf("ssh: permission denied (publickey)"), OffsiteFailTransport},
|
|
{"unclassified", fmt.Errorf("restic: some future error nobody has seen"), OffsiteFailUnknown},
|
|
}
|
|
seen := map[OffsiteFailureClass]bool{}
|
|
for _, c := range cases {
|
|
got := ClassifyOffsiteFailure(c.err)
|
|
if got != c.want {
|
|
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
|
|
}
|
|
seen[got] = true
|
|
}
|
|
// The whole point of F-DIAG: the causes must not collapse.
|
|
if len(seen) < 5 {
|
|
t.Errorf("only %d distinct classes across %d causes — the causes are still collapsing", len(seen), len(cases))
|
|
}
|
|
}
|
|
|
|
// An unclassifiable error must say so rather than being folded into a neighbour. Inventing a precision
|
|
// the code does not have is how a confident-but-wrong diagnosis ships.
|
|
func TestClassifyOffsiteFailure_UnknownIsHonest(t *testing.T) {
|
|
if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown {
|
|
t.Errorf("an unclassifiable error was folded into %q instead of being reported as unknown", got)
|
|
}
|
|
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute, "hu")
|
|
if !strings.Contains(msg, "ismeretlen okból") {
|
|
t.Errorf("the unknown case does not admit it is unknown: %q", msg)
|
|
}
|
|
}
|
|
|
|
// THE SECRETS TEST. The repo reference must never survive into a message.
|
|
//
|
|
// RED-PROOF: make sanitiseOffsiteError return err.Error() unchanged → this fails with
|
|
// "the repo reference reached the message".
|
|
func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
|
|
leaky := []error{
|
|
fmt.Errorf(`Fatal: unable to open repository at sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo: connection refused`),
|
|
fmt.Errorf(`ssh: connect to host u629488-sub3.your-storagebox.de port 23: Connection refused`),
|
|
fmt.Errorf(`restic: repo "sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo" locked`),
|
|
}
|
|
for _, e := range leaky {
|
|
msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second, "hu")
|
|
for _, forbidden := range []string{
|
|
"sftp:",
|
|
"your-storagebox.de",
|
|
"u629488-sub3",
|
|
"/home/felhom-repo",
|
|
} {
|
|
if strings.Contains(msg, forbidden) {
|
|
t.Errorf("the repo reference reached the message (%q leaked):\n %s", forbidden, msg)
|
|
}
|
|
}
|
|
if !strings.Contains(msg, "<repo>") {
|
|
t.Errorf("the redaction placeholder is absent — the detail may have been dropped silently instead of sanitised:\n %s", msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The message must still be ACTIONABLE. Sanitising must not reduce it to a shrug — an operator needs
|
|
// the cause line plus enough residual detail to act.
|
|
func TestOffsiteFailureMessage_StaysActionable(t *testing.T) {
|
|
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second, "hu")
|
|
if !strings.Contains(msg, "nem érhető el") {
|
|
t.Errorf("the transport cause is not named: %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "connection refused") {
|
|
t.Errorf("all actionable detail was stripped along with the secret: %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "1m30s") {
|
|
t.Errorf("the duration was lost: %q", msg)
|
|
}
|
|
}
|
|
|
|
// A very long error must be bounded — an unbounded restic dump in an email is its own problem.
|
|
func TestSanitiseOffsiteError_IsBounded(t *testing.T) {
|
|
long := fmt.Errorf("%s", strings.Repeat("x", 5000))
|
|
if got := sanitiseOffsiteErrorFor(diagTarget(), long); len(got) > 320 {
|
|
t.Errorf("sanitised error is %d chars — unbounded", len(got))
|
|
}
|
|
if sanitiseOffsiteErrorFor(diagTarget(), nil) != "" {
|
|
t.Error("a nil error produced text")
|
|
}
|
|
}
|
|
|
|
// newNoteManager builds a Manager whose saved-note helpers work: release C writes a saved note in the
|
|
// BOX's language, so a Manager with no settings would render every note as its key. Hungarian here,
|
|
// because these tests assert the sentence a Hungarian household reads — which is the parity half.
|
|
func newNoteManager(t *testing.T) *Manager {
|
|
t.Helper()
|
|
lg := log.New(io.Discard, "", 0)
|
|
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = t.TempDir()
|
|
return NewManager(cfg, sett, lg)
|
|
}
|
|
|
|
// backupNoteHU is the Hungarian text of a saved-note key, for a test that used to compare against a
|
|
// constant. The comparison is unchanged in meaning: it still pins the sentence, now measured against
|
|
// the bundle rather than restated beside it.
|
|
func backupNoteHU(t *testing.T, key string) string {
|
|
t.Helper()
|
|
return newNoteManager(t).note(key)
|
|
}
|
|
|
|
// TestR570SentenceStaysHungarian — the ONE saved note release C may not translate.
|
|
//
|
|
// A box upgraded to 0.251.0 carries the OLD persisted warning with no kind until its next off-site
|
|
// run rewrites it, so `offboxWarningDisplay` still falls back to a substring test on those words when
|
|
// the kind is empty (R-553's documented exception). Translating the PRODUCER while that fallback is
|
|
// load-bearing would strand exactly the boxes the fallback exists for: their stale note would stop
|
|
// being recognised and would keep telling a household that nothing is covered.
|
|
//
|
|
// **Delete this test when R-570 closes** — it is named for the row on purpose.
|
|
//
|
|
// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line.
|
|
func TestR570SentenceStaysHungarian(t *testing.T) {
|
|
const sentence = "Sikeres — nincs mentésre jelölt alkalmazás"
|
|
src, err := os.ReadFile("offbox.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(src), `"`+sentence+`"`) {
|
|
t.Errorf("the R-570 producer no longer writes %q as a literal — a box that has not run off-site "+
|
|
"since 0.251.0 carries that exact text with no kind, and offboxWarningDisplay finds it by "+
|
|
"those words. Translating it strands them. Close R-570 first.", sentence)
|
|
}
|
|
// And the sentence must not have quietly acquired a bundle key either: a key would render
|
|
// Hungarian today and something else the day someone adds a translation.
|
|
for _, k := range []string{"note.offsite.no_apps_selected", "note.offsite.zero_toggle"} {
|
|
if strings.Contains(string(src), k) {
|
|
t.Errorf("the R-570 producer now names a bundle key (%s) — same problem, one step further away", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test,
|
|
// not a precaution.
|
|
//
|
|
// `UpdateOffboxStatus` and its siblings hold the settings WRITE lock while they run their callback.
|
|
// `boxLang()` reads the language through the settings READ lock. sync.RWMutex is not reentrant, so a
|
|
// note rendered inside such a callback DEADLOCKS — and it deadlocks while holding the settings lock,
|
|
// which then wedges every other thing on that box that touches settings.json. The first draft of
|
|
// release C did exactly that, in the off-site run's final status write, and the only symptom was the
|
|
// test suite timing out at 25 minutes.
|
|
//
|
|
// The fix is to resolve the language BEFORE entering the callback. This test reads the source for the
|
|
// shape, because the failure is a hang and a hang is not something a unit test can assert on
|
|
// comfortably; the behaviour half is the suite finishing at all.
|
|
//
|
|
// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test
|
|
// names the file and the line, in a second, instead of the suite hanging for 25 minutes.
|
|
func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) {
|
|
callback := regexp.MustCompile(`\.Update\w*\(func\(`)
|
|
note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`)
|
|
|
|
files, err := filepath.Glob("*.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checked, callbacks := 0, 0
|
|
var bad []string
|
|
for _, f := range files {
|
|
if strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
src, err := os.ReadFile(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checked++
|
|
depth := 0
|
|
inside := false
|
|
for i, line := range strings.Split(string(src), "\n") {
|
|
if !inside && callback.MatchString(line) {
|
|
depth = strings.Count(line, "{") - strings.Count(line, "}")
|
|
if depth > 0 {
|
|
inside, callbacks = true, callbacks+1
|
|
}
|
|
continue
|
|
}
|
|
if !inside {
|
|
continue
|
|
}
|
|
if note.MatchString(line) {
|
|
bad = append(bad, f+":"+strconv.Itoa(i+1)+" "+strings.TrimSpace(line))
|
|
}
|
|
depth += strings.Count(line, "{") - strings.Count(line, "}")
|
|
if depth <= 0 {
|
|
inside = false
|
|
}
|
|
}
|
|
}
|
|
// The instrument must be shown to be looking at something.
|
|
if checked == 0 || callbacks == 0 {
|
|
t.Fatalf("examined %d files and found %d settings callbacks — the pattern no longer matches the code", checked, callbacks)
|
|
}
|
|
if len(bad) > 0 {
|
|
t.Errorf("a saved-note helper is called inside a settings callback, which holds the WRITE lock "+
|
|
"while boxLang() wants the READ lock — sync.RWMutex is not reentrant, so this DEADLOCKS and "+
|
|
"wedges settings.json for everything else on the box. Resolve the language before the "+
|
|
"callback (%d):\n %s", len(bad), strings.Join(bad, "\n "))
|
|
}
|
|
t.Logf("examined %d files, %d settings callbacks", checked, callbacks)
|
|
}
|