3f048e042b
Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction.
122 lines
5.3 KiB
Go
122 lines
5.3 KiB
Go
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:<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
|
|
}{
|
|
{"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, "<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")
|
|
}
|
|
}
|