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
102 lines
5.1 KiB
Go
102 lines
5.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
)
|
|
|
|
// R-553 — the off-site failure classifier must keep telling a quota over-run apart after the sentence
|
|
// is translated. It used to look for the Hungarian word „tárhelykeretet" inside the error; slice 2
|
|
// translates that sentence, and the customer would then be told the copy failed „ismeretlen okból"
|
|
// — unknown cause — for the one failure with a clear, actionable cause.
|
|
//
|
|
// RED-PROOF (REPORT): put `case strings.Contains(s, "tárhelykeretet")` back and delete the
|
|
// errors.Is arm → the translated row falls to OffsiteFailUnknown and this fails.
|
|
func TestR553_OffsiteQuota_DecisionSurvivesWordingChange(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want OffsiteFailureClass
|
|
}{
|
|
{"quota refusal, Hungarian as shipped", util.KindErrorf(ErrOffsiteQuota,
|
|
"A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", 51, 50), OffsiteFailQuota},
|
|
{"quota refusal, TRANSLATED", util.KindErrorf(ErrOffsiteQuota,
|
|
"The remote backup is over its storage quota (%d/%d GB) — delete old backups or ask for more space.", 51, 50), OffsiteFailQuota},
|
|
{"quota refusal wrapped by a caller", util.KindErrorf(ErrOffsiteQuota, "over quota"), OffsiteFailQuota},
|
|
// Negative controls: output we do NOT write stays matched by its text, on purpose.
|
|
{"restic: no repository", errors.New("Fatal: unable to open config file: Stat: file does not exist"), OffsiteFailNoRepo},
|
|
{"ssh: unreachable", errors.New("dial tcp 10.0.0.9:22: connect: connection refused"), OffsiteFailTransport},
|
|
{"nothing to copy", errors.New("off-box backup produced no snapshots: 2 app(s) toggled"), OffsiteFailNoUnits},
|
|
{"unknown stays unknown", errors.New("something else entirely"), OffsiteFailUnknown},
|
|
{"no error", nil, ""},
|
|
}
|
|
for _, c := range cases {
|
|
if got := ClassifyOffsiteFailure(c.err); got != c.want {
|
|
t.Errorf("%s: ClassifyOffsiteFailure = %q, want %q", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The consequence, not only the mechanism: the head line the customer reads on /backups/remote is the
|
|
// quota one for a TRANSLATED quota error. (offsiteFailureMessage is the only caller of the classifier.)
|
|
func TestR553_OffsiteQuota_HeadLineSurvivesWordingChange(t *testing.T) {
|
|
tgt := &settings.OffboxTarget{Host: "nas.local", User: "felhom", RepoPath: "/srv/repo"}
|
|
translated := util.KindErrorf(ErrOffsiteQuota, "The remote backup is over its storage quota (51/50 GB).")
|
|
msg := offsiteFailureMessage(tgt, translated, 12*time.Second, "hu")
|
|
if !strings.HasPrefix(msg, "A távoli mentés nem fért el a tárhelykereten belül") {
|
|
t.Errorf("a translated quota failure is reported with the wrong cause line: %q", msg)
|
|
}
|
|
unknown := errors.New("The remote backup is over its storage quota (51/50 GB).")
|
|
if m := offsiteFailureMessage(tgt, unknown, time.Second, "hu"); strings.HasPrefix(m, "A távoli mentés nem fért el") {
|
|
t.Errorf("an error WITHOUT the kind must not be guessed into the quota class from its words: %q", m)
|
|
}
|
|
}
|
|
|
|
// The producer keeps its Hungarian sentence byte-for-byte while carrying the kind.
|
|
func TestR553_QuotaProducerKeepsItsWords(t *testing.T) {
|
|
err := util.KindErrorf(ErrOffsiteQuota,
|
|
"A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", 51, 50)
|
|
want := "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."
|
|
if err.Error() != want {
|
|
t.Errorf("message CHANGED:\n got %q\nwant %q", err.Error(), want)
|
|
}
|
|
if !errors.Is(err, ErrOffsiteQuota) {
|
|
t.Error("the quota refusal carries no kind")
|
|
}
|
|
}
|
|
|
|
// The zero-selection run must RECORD its kind beside the sentence, or the page falls back to reading
|
|
// the words for ever. A real off-site run needs restic and an SSH target, so this is pinned at the
|
|
// source — the defect it guards is a producer written the old way, and the display half is covered by
|
|
// TestR553_StaleNoteDecisionSurvivesWordingChange in internal/web.
|
|
func TestR553_OffboxRunRecordsTheKind(t *testing.T) {
|
|
src, err := os.ReadFile("offbox.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(src)
|
|
i := strings.Index(body, `warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")`)
|
|
if i < 0 {
|
|
t.Fatal("the zero-selection sentence is gone from the run — this test no longer reads what it thinks it reads")
|
|
}
|
|
if !strings.Contains(body[i:i+400], "warnKind = OffboxWarnNoAppsSelected") {
|
|
t.Error("the zero-selection run records its sentence but not its KIND — the Távoli mentés page " +
|
|
"is left reading Hungarian words, which localisation slice 2 will change (R-553)")
|
|
}
|
|
if !strings.Contains(body, "o.LastWarningKind = warnKind") {
|
|
t.Error("the recorded kind is never persisted, so the page sees nothing after a restart")
|
|
}
|
|
for _, clear := range []string{`o.LastWarning = ""
|
|
o.LastWarningKind = ""`} {
|
|
if !strings.Contains(body, clear) {
|
|
t.Error("a run that clears LastWarning must clear its kind too, or a stale kind outlives its text")
|
|
}
|
|
}
|
|
}
|