package backup import ( "fmt" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // 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:@:`, 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 }{ {"quota gate", fmt.Errorf("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, "") { 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") } }