Files
felhom-controller/controller/internal/backup/r553_offsite_quota_test.go
T
admin c00fed6db3 R-553: four decisions stop reading their own Hungarian words (sites 1-4)
Every Hungarian sentence is byte-identical; each decision now reads a signal set where the message is
made. util.KindErrorf builds the same bytes fmt.Errorf did while carrying a sentinel for errors.Is.

- Deploy status (api/router.go): deployStatusFor() by kind — stacks.ErrAlreadyDeployed (409),
  ErrRequiredField / ErrPathMissing / ErrNotEnoughMemory (400). The „kötelező" / „memória" /
  "does not exist" / "already deployed" text chain is gone.
- Off-site failure class (backup/offbox.go): ErrOffsiteQuota replaces the „tárhelykeretet" match. The
  restic/ssh signatures stay text matches on purpose — that output is not ours and is not translated.
- Alert placement (web/alerts.go): monitor.HealthReport carries WarningKinds parallel to Warnings;
  the "not on a separate drive" warning is inline by KIND. The hub report is untouched (builder.go
  copies Status/Issues/Warnings only) — pinned by a wire test.
- Stale off-site note (web/handlers.go): settings LastWarningKind + backup.OffboxWarnNoAppsSelected.
  The text test survives ONLY for kind == "" (a box whose last run predates 0.251.0) and is removed
  when R-570 closes; slice 2 must not translate that producer before then.

Tests (all red-proofed by restoring the pre-fix predicate — see the audit's redproofs.txt):
TestR553_Deploy_DecisionSurvivesWordingChange, TestR553_DeployHandlerUsesTheKind,
TestR553_DeployProducersCarryKindAndKeepTheirWords (through the real DeployStack),
TestR553_OffsiteQuota_{Decision,HeadLine}SurvivesWordingChange, TestR553_OffboxRunRecordsTheKind,
TestR553_StorageWarningsCarryKindsAndKeepTheirWords, TestR553_DiskWarningPlacementSurvivesWordingChange,
TestR553_HubReportWarningsAreUnchangedOnTheWire, TestR553_StaleNote*, TestR553_WarningKindIsPersistedAndCopied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 20:58:45 +02:00

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)
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); 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")
}
}
}