fc796dd95e
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
246 lines
9.1 KiB
Go
246 lines
9.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// classifyResticProbe maps the exact restic stderr to a repo class (the 2026-07-17 diagnosis
|
|
// signatures). ORPHANED only on the definitive wrong-password line; ambiguous errors are NOT orphaned.
|
|
func TestClassifyResticProbe(t *testing.T) {
|
|
cases := []struct {
|
|
out string
|
|
err error
|
|
want string
|
|
}{
|
|
{"", nil, ""}, // success
|
|
{"Fatal: wrong password or no key found", fmt.Errorf("exit status 1"), "orphaned"},
|
|
{"Fatal: unable to open config file: <sftp:...> does not exist\nIs there a repository at the following location?", fmt.Errorf("exit status 1"), "norepo"},
|
|
{"ssh: connect to host nas.local port 22: Connection timed out", fmt.Errorf("exit status 255"), "other"},
|
|
{"Load(<lock/...>): permission denied", fmt.Errorf("exit status 1"), "other"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := classifyResticProbe([]byte(c.out), c.err); got != c.want {
|
|
t.Errorf("classify(%q) = %q, want %q", c.out, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// wrongPwRunner: `cat config` returns the wrong-password signature; other restic steps succeed (so a
|
|
// post-reset run can proceed). Records the subcommands seen.
|
|
func wrongPwRunner(seen *[]string) offboxRunner {
|
|
return func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
sub := ""
|
|
for i, a := range args {
|
|
if a == "cat" && i+1 < len(args) && args[i+1] == "config" {
|
|
sub = "cat-config"
|
|
} else if a == "init" {
|
|
sub = "init"
|
|
}
|
|
}
|
|
if sub == "" && len(args) > 0 {
|
|
sub = args[len(args)-1]
|
|
}
|
|
if seen != nil {
|
|
*seen = append(*seen, sub)
|
|
}
|
|
if sub == "cat-config" {
|
|
return []byte("Fatal: wrong password or no key found"), fmt.Errorf("exit status 1")
|
|
}
|
|
return nil, nil // init / unlock / backup / stats succeed
|
|
}
|
|
}
|
|
|
|
// Scenario A (RED-PROOF = the incident): a CLAIMED box whose repo is wrong-keyed enters the explicit
|
|
// ORPHANED state — the run skips cleanly (no raw restic banner, ONE event, no nightly re-fire) instead
|
|
// of erroring nightly with "exit status 1". Pre-fix (no classification) surfaced the raw error and set
|
|
// no state → these assertions FAIL.
|
|
func TestOffbox_OrphanDetection_Claimed(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
if err := sett.SetClaimed(); err != nil { // claimed AND has made copies before → orphan card, NEVER auto-reset
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastSuccess = "2026-10-01T02:00:00Z" }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var events []string
|
|
m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) })
|
|
m.SetOffboxRunner(wrongPwRunner(nil))
|
|
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("run should skip cleanly on an orphaned repo, got %v", err)
|
|
}
|
|
if !m.OffboxOrphaned() {
|
|
t.Fatal("repo was not classified/persisted as ORPHANED")
|
|
}
|
|
if len(events) != 1 || events[0] != "offbox_repo_orphaned" {
|
|
t.Fatalf("expected exactly one offbox_repo_orphaned event, got %v", events)
|
|
}
|
|
got := sett.GetOffboxTarget()
|
|
if got.RepoState != "orphaned" || got.OrphanedAt == "" {
|
|
t.Fatalf("RepoState=%q OrphanedAt=%q, want orphaned + a stamp", got.RepoState, got.OrphanedAt)
|
|
}
|
|
// The raw restic error must NOT be surfaced as the last-error banner (the card explains instead).
|
|
if strings.Contains(got.LastError, "wrong password") || strings.Contains(got.LastError, "exit status") {
|
|
t.Fatalf("raw restic error leaked into LastError: %q", got.LastError)
|
|
}
|
|
// A second scheduled run SKIPS (no nightly spam) — no new event.
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("second run: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("nightly re-fire — events=%v, want the single transition event only", events)
|
|
}
|
|
}
|
|
|
|
// Scenario B: an UNCLAIMED box auto-resets on detection — move-aside (never delete) + re-init; both
|
|
// events fire and the box ends un-orphaned (next run green).
|
|
func TestOffbox_OrphanDetection_UnclaimedAutoReset(t *testing.T) {
|
|
m, sett := newOffboxManager(t) // unclaimed by default
|
|
var events []string
|
|
m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) })
|
|
var sshCmds []string
|
|
m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, remoteCmd string) ([]byte, error) {
|
|
sshCmds = append(sshCmds, remoteCmd)
|
|
if strings.HasPrefix(remoteCmd, "test -e") {
|
|
return nil, fmt.Errorf("exit status 1") // absent → free name
|
|
}
|
|
return nil, nil // mv OK
|
|
})
|
|
m.SetOffboxRunner(wrongPwRunner(nil))
|
|
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("unclaimed run should auto-reset + succeed, got %v", err)
|
|
}
|
|
if m.OffboxOrphaned() {
|
|
t.Fatal("unclaimed box stayed orphaned — auto-reset did not clear the state")
|
|
}
|
|
got := sett.GetOffboxTarget()
|
|
if got.OrphanedRenamedTo == "" || !strings.Contains(got.OrphanedRenamedTo, ".orphaned-") {
|
|
t.Fatalf("move-aside path not recorded: %q", got.OrphanedRenamedTo)
|
|
}
|
|
var mvSeen bool
|
|
for _, c := range sshCmds {
|
|
if strings.HasPrefix(c, "mv ") {
|
|
mvSeen = true
|
|
}
|
|
}
|
|
if !mvSeen {
|
|
t.Fatalf("no move-aside mv issued: %v", sshCmds)
|
|
}
|
|
// Both transition events fired (orphaned → reset). No delete anywhere.
|
|
if len(events) != 2 || events[0] != "offbox_repo_orphaned" || events[1] != "offbox_repo_reset" {
|
|
t.Fatalf("events = %v, want [orphaned reset]", events)
|
|
}
|
|
}
|
|
|
|
// Scenario C: the claimed confirmed reset (ResetOrphanedRepo) refuses unless orphaned, then move-aside +
|
|
// re-init + clear state.
|
|
func TestOffbox_ConfirmedReset(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
if err := sett.SetClaimed(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// refuse when not orphaned
|
|
if err := m.ResetOrphanedRepo(context.Background()); err == nil {
|
|
t.Fatal("reset must refuse when the repo is not orphaned")
|
|
}
|
|
// mark orphaned, then confirm reset
|
|
m.SetOffboxRunner(wrongPwRunner(nil))
|
|
_ = m.RunOffboxBackup(context.Background())
|
|
if !m.OffboxOrphaned() {
|
|
t.Fatal("precondition: not orphaned")
|
|
}
|
|
var mv bool
|
|
m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, cmd string) ([]byte, error) {
|
|
if strings.HasPrefix(cmd, "test -e") {
|
|
return nil, fmt.Errorf("exit 1")
|
|
}
|
|
if strings.HasPrefix(cmd, "mv ") {
|
|
mv = true
|
|
}
|
|
return nil, nil
|
|
})
|
|
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
|
t.Fatalf("confirmed reset: %v", err)
|
|
}
|
|
if !mv {
|
|
t.Fatal("confirmed reset did not move the old repo aside")
|
|
}
|
|
if m.OffboxOrphaned() {
|
|
t.Fatal("state not cleared after confirmed reset")
|
|
}
|
|
}
|
|
|
|
// R-726, decision 78: a CLAIMED box that has NEVER made an off-site copy (a returning household's new box, night
|
|
// one) sets the old copy aside by itself and makes its first copy — as an unclaimed box does. The old copy is
|
|
// moved, never deleted, and recorded so it can be put back. Red-proof: drop `|| firstNight` and this fails while
|
|
// TestOffbox_OrphanDetection_Claimed still passes.
|
|
func TestR726_ReturningHouseholdFirstNightSetsAside(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
if err := sett.SetClaimed(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var events []string
|
|
m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) })
|
|
var sshCmds []string
|
|
m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, remoteCmd string) ([]byte, error) {
|
|
sshCmds = append(sshCmds, remoteCmd)
|
|
if strings.HasPrefix(remoteCmd, "test -e") {
|
|
return nil, fmt.Errorf("exit status 1")
|
|
}
|
|
return nil, nil
|
|
})
|
|
var seen []string
|
|
m.SetOffboxRunner(wrongPwRunner(&seen))
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("the first night must make a copy, got %v", err)
|
|
}
|
|
if m.OffboxOrphaned() {
|
|
t.Fatal("the returning household's box stayed orphaned")
|
|
}
|
|
got := sett.GetOffboxTarget()
|
|
if !strings.Contains(got.OrphanedRenamedTo, ".orphaned-") {
|
|
t.Fatalf("the old copy's new place is not recorded (it must be listable and restorable): %q", got.OrphanedRenamedTo)
|
|
}
|
|
for _, c := range sshCmds {
|
|
if strings.HasPrefix(c, "rm ") || strings.Contains(c, "rm -") {
|
|
t.Fatalf("something was deleted: %q", c)
|
|
}
|
|
}
|
|
backedUp := false
|
|
for _, s := range seen {
|
|
backedUp = backedUp || s == "init"
|
|
}
|
|
if !backedUp {
|
|
t.Fatalf("no fresh repository was started: %v", seen)
|
|
}
|
|
if len(events) != 2 || events[0] != "offbox_repo_orphaned" || events[1] != "offbox_repo_reset" {
|
|
t.Fatalf("events = %v, want [orphaned reset]", events)
|
|
}
|
|
}
|
|
|
|
// A box whose repository is NOT orphaned changes nothing (no move, no reset event).
|
|
func TestR726_NotOrphanedChangesNothing(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
_ = sett.SetClaimed()
|
|
var events []string
|
|
m.SetOffboxOrphanEvent(func(evt, _ string) { events = append(events, evt) })
|
|
var sshCmds []string
|
|
m.SetOffboxSSH(func(_ context.Context, _, _ string, _ int, _, _, remoteCmd string) ([]byte, error) {
|
|
sshCmds = append(sshCmds, remoteCmd)
|
|
return nil, nil
|
|
})
|
|
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { return nil, nil })
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if len(events) != 0 || len(sshCmds) != 0 || sett.GetOffboxTarget().OrphanedRenamedTo != "" {
|
|
t.Fatalf("a healthy repository was touched: events=%v ssh=%v", events, sshCmds)
|
|
}
|
|
}
|