c00fed6db3
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
127 lines
5.7 KiB
Go
127 lines
5.7 KiB
Go
package backup
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
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)
|
|
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)
|
|
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")
|
|
}
|
|
}
|