package backup import ( "context" "encoding/json" "errors" "log" "os" "path/filepath" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // newOffboxManager builds a Manager with a temp data dir + a configured + enabled off-box target and the // 0600 secret files written, so OffboxConfigured() is true. func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) { t.Helper() logger := log.New(os.Stderr, "", 0) dataDir := t.TempDir() sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = dataDir cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys") m := NewManager(cfg, sett, logger) if err := sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed", // fork-4: default the harness to escrowed so behavioral run tests exercise the run path }); err != nil { t.Fatal(err) } if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { t.Fatal(err) } return m, sett } // argsContainTimeout reports whether the restic arg vector carries the load-bearing ConnectTimeout. func argsContainTimeout(args []string) bool { return strings.Contains(strings.Join(args, " "), "-oConnectTimeout=") } // TestOffbox_BaseArgsCarryConnectTimeout asserts the mandatory fail-fast + hardening args are present. func TestOffbox_BaseArgsCarryConnectTimeout(t *testing.T) { m, sett := newOffboxManager(t) base, env := m.offboxBaseArgs(sett.GetOffboxTarget()) joined := strings.Join(base, " ") for _, want := range []string{ "-oConnectTimeout=10", // THE spike Q8 fail-fast knob "-oStrictHostKeyChecking=yes", // no blind TOFU "-oUserKnownHostsFile=", // pinned host key "-oBatchMode=yes", // no interactive hang "sftp:felhom@nas.local:/srv/repo", } { if !strings.Contains(joined, want) { t.Errorf("base args missing %q: %s", want, joined) } } if len(env) != 1 || !strings.HasPrefix(env[0], "RESTIC_PASSWORD_FILE=") { t.Errorf("env must set RESTIC_PASSWORD_FILE only, got %v", env) } } // failFastFake models the SSH transport's ConnectTimeout honoring: if the restic args carry // -oConnectTimeout it returns a connect error promptly (fail-fast); WITHOUT it, it blocks until the ctx // deadline (the dead-NAS multi-minute hang). This is the seam the ConnectTimeout companion exercises. func failFastFake(_ *testing.T) offboxRunner { return func(ctx context.Context, _ []string, args ...string) ([]byte, error) { if argsContainTimeout(args) { return []byte("dial tcp: connect: connection refused"), context.DeadlineExceeded // fast, bounded } <-ctx.Done() // no timeout arg → hang until the caller's deadline (the bug) return nil, ctx.Err() } } // TestOffbox_ConnectTimeoutIsLoadBearing is the §10 companion red-proof: WITH the arg the (fake) connect // fails fast (well under the bound); WITHOUT it the connect blocks past the bound. A build that dropped // the ConnectTimeout arg would take the slow path → this proves the arg is load-bearing. func TestOffbox_ConnectTimeoutIsLoadBearing(t *testing.T) { m, sett := newOffboxManager(t) realArgs, env := m.offboxBaseArgs(sett.GetOffboxTarget()) fake := failFastFake(t) // WITH the arg: returns promptly (we model fast as an immediate error, not a ctx hang). ctx1, c1 := context.WithTimeout(context.Background(), 2*time.Second) defer c1() start := time.Now() _, err := fake(ctx1, env, append(append([]string{}, realArgs...), "cat", "config")...) if elapsed := time.Since(start); elapsed > time.Second { t.Fatalf("with ConnectTimeout the connect must fail fast, took %s", elapsed) } _ = err // WITHOUT the arg (the bug): blocks until the ctx deadline. stripped := stripConnectTimeout(realArgs) if argsContainTimeout(stripped) { t.Fatal("test setup: stripped args still contain the timeout") } ctx2, c2 := context.WithTimeout(context.Background(), 300*time.Millisecond) defer c2() start = time.Now() _, err = fake(ctx2, env, append(append([]string{}, stripped...), "cat", "config")...) if err != context.DeadlineExceeded { t.Fatalf("without ConnectTimeout the connect should block to the deadline, got %v", err) } if elapsed := time.Since(start); elapsed < 250*time.Millisecond { t.Fatalf("without ConnectTimeout it should have hung to the bound, only took %s", elapsed) } } func stripConnectTimeout(args []string) []string { out := make([]string, len(args)) for i, a := range args { out[i] = strings.ReplaceAll(a, "-oConnectTimeout=10 ", "") } return out } // TestOffbox_RunFailsFastAndAlerts: a dead-NAS run returns an error promptly, records status=error, and // fires the operator alert. func TestOffbox_RunFailsFastAndAlerts(t *testing.T) { m, sett := newOffboxManager(t) m.SetOffboxRunner(func(ctx context.Context, _ []string, args ...string) ([]byte, error) { // dead NAS: every op (incl. the repo probe) errors fast. return []byte("unable to open repository: connection refused"), context.DeadlineExceeded }) var mu sync.Mutex var gotErr error var notified bool m.SetOffboxNotify(func(_ time.Duration, _ int, err error) { mu.Lock(); defer mu.Unlock(); notified = true; gotErr = err }) _ = sett.SetAppOffbox("rallly", true) start := time.Now() err := m.RunOffboxBackup(context.Background()) if err == nil { t.Fatal("a dead NAS must produce a failed run") } if time.Since(start) > 5*time.Second { t.Fatalf("run should fail fast, took %s", time.Since(start)) } if !notified || gotErr == nil { t.Fatal("a failed off-box run must alert the operator") } if st := sett.GetOffboxTarget(); st.LastStatus != "error" || st.LastError == "" { t.Fatalf("status must record the failure, got %+v", st) } } // TestOffbox_RepoIdempotent: when the repo exists (cat config succeeds), ensure must NOT init. func TestOffbox_RepoIdempotent(t *testing.T) { m, sett := newOffboxManager(t) var inits int m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "cat") && contains(args, "config"): return []byte(`{"version":2}`), nil // repo exists case contains(args, "init"): inits++ return nil, nil } return nil, nil }) base, env := m.offboxBaseArgs(sett.GetOffboxTarget()) if err := m.ensureOffboxRepo(context.Background(), base, env); err != nil { t.Fatal(err) } if inits != 0 { t.Fatalf("an existing repo must NOT be re-initialized, init called %d times", inits) } } // TestOffbox_RestoreRoundTrip: backup a temp tree (fake records src per tag), restore (fake copies the // recorded tree to target) → byte-identical. Exercises the orchestration without real restic. func TestOffbox_RestoreRoundTrip(t *testing.T) { m, sett := newOffboxManager(t) // Lay down an app's recovery-unit tree on disk (what RunOffboxBackup will back up). nsRoot := m.AppNamespaceRoot("rallly") src := RecoveryUnitPath(nsRoot, "rallly") if err := os.MkdirAll(filepath.Join(src, "db-dumps"), 0o755); err != nil { t.Fatal(err) } want := []byte("CREATE TABLE x; -- dump bytes") if err := os.WriteFile(filepath.Join(src, "db-dumps", "rallly.sql"), want, 0o644); err != nil { t.Fatal(err) } _ = sett.SetAppOffbox("rallly", true) captured := map[string]string{} // tag → src path m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "cat") && contains(args, "config"): return []byte(`{}`), nil case contains(args, "backup"): captured[tagOf(args)] = args[len(args)-1] // last arg = src path return nil, nil case contains(args, "forget"): return nil, nil case contains(args, "restore"): target := valAfter(args, "--target") if err := copyTree(captured[tagOf(args)], target); err != nil { return nil, err } return nil, nil case contains(args, "snapshots"): return []byte(`[{"id":"abc"}]`), nil case contains(args, "stats"): return []byte(`{"total_size":123}`), nil } return nil, nil }) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("backup: %v", err) } dest := t.TempDir() if err := m.RestoreOffbox(context.Background(), "rallly", dest); err != nil { t.Fatalf("restore: %v", err) } got, err := os.ReadFile(filepath.Join(dest, "db-dumps", "rallly.sql")) if err != nil { t.Fatalf("restored file missing: %v", err) } if string(got) != string(want) { t.Fatalf("restore not byte-identical: got %q want %q", got, want) } } // TestOffbox_SingleFlight: an off-box run while another backup holds m.running skips (no runner call). func TestOffbox_SingleFlight(t *testing.T) { m, _ := newOffboxManager(t) called := false m.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) { called = true; return nil, nil }) _ = m.acquireRunning() // simulate a concurrent backup holding the flag defer m.releaseRunning() if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("single-flight skip should not error, got %v", err) } if called { t.Fatal("off-box must not run (race) while another backup holds the flag") } } // TestOffbox_SecretsAre0600: the SSH key + repo password files are 0600; the password is non-empty. func TestOffbox_SecretsAre0600(t *testing.T) { m, _ := newOffboxManager(t) for _, p := range []string{m.offboxKeyPath(), m.offboxPwPath()} { info, err := os.Stat(p) if err != nil { t.Fatalf("secret file missing: %v", err) } if runtimeIsUnix() && info.Mode().Perm()&0o077 != 0 { t.Errorf("%s is group/other-readable (mode %v) — must be 0600", p, info.Mode().Perm()) } } pw, _ := os.ReadFile(m.offboxPwPath()) if len(strings.TrimSpace(string(pw))) < 32 { t.Errorf("repo password too short / empty") } } // --- tiny test helpers --- // TestOffbox_ValidateRejectsInjection is the security companion: host/user/repo values that could inject // an ssh option (leading '-' → e.g. -oProxyCommand) or a shell metacharacter must be REFUSED; a clean // target is accepted. A build without this guard would let a hostile target reach the ssh exec → FAIL. func TestOffbox_ValidateRejectsInjection(t *testing.T) { ok := &settings.OffboxTarget{Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo"} if err := ValidateOffboxTarget(ok); err != nil { t.Fatalf("clean target rejected: %v", err) } bad := []settings.OffboxTarget{ {Host: "-oProxyCommand=touch /tmp/pwn", User: "felhom", RepoPath: "/srv/repo"}, // ssh option injection {Host: "nas;rm -rf /", User: "felhom", RepoPath: "/srv/repo"}, // metacharacters {Host: "nas.local", User: "-oProxyCommand=x", RepoPath: "/srv/repo"}, // user option injection {Host: "nas.local", User: "felhom", RepoPath: "/srv/repo; evil"}, // path metacharacters {Host: "nas.local", User: "felhom", RepoPath: "/srv/../etc"}, // traversal {Host: "nas local", User: "felhom", RepoPath: "/srv/repo"}, // space {Host: "nas.local", User: "felhom", RepoPath: "relative/path"}, // non-absolute } for i, b := range bad { bb := b if err := ValidateOffboxTarget(&bb); err == nil { t.Errorf("case %d (%+v) must be rejected", i, bb) } } } // --- Off-box unit DISCOVERY + no-silent-success (§7 A–E) --- // recordingOffboxRunner captures the exact `src` path of each restic `backup` call and returns success // for the repo probe/init/snapshots/stats/forget. Per-stack `backup` errors are configurable. type recordingOffboxRunner struct { backupSrc []string // src path per backup call, in order (the load-bearing effect to assert) backupErr map[string]error // stack (last --tag) → error to return from `backup` } func (rr *recordingOffboxRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "cat") && contains(args, "config"): return []byte(`{"version":2}`), nil // repo exists (no init) case contains(args, "backup"): rr.backupSrc = append(rr.backupSrc, args[len(args)-1]) // last arg = src path if e := rr.backupErr[tagOf(args)]; e != nil { return []byte("restic backup failed"), e } return nil, nil case contains(args, "forget"): return nil, nil case contains(args, "snapshots"): return []byte(`[{"id":"s1"}]`), nil case contains(args, "stats"): return []byte(`{"total_size":123}`), nil } return nil, nil } // addSchedulablePath registers a schedulable (non-decommissioned) storage path — a candidate drive. func addSchedulablePath(t *testing.T, sett *settings.Settings, p string) { t.Helper() if err := sett.AddStoragePath(settings.StoragePath{Path: p, Schedulable: true, AddedAt: "2026-07-01T00:00:00Z"}); err != nil { t.Fatal(err) } } // writeUnit lays a recovery unit (dir + a manifest with the given CreatedAt) at /backups/primary/. func writeUnit(t *testing.T, nsRoot, app, createdAt string) { t.Helper() if err := os.MkdirAll(RecoveryUnitPath(nsRoot, app), 0o755); err != nil { t.Fatal(err) } b, _ := json.Marshal(RecoveryManifest{AppName: app, CreatedAt: createdAt}) if err := os.WriteFile(RecoveryUnitManifestPath(nsRoot, app), b, 0o644); err != nil { t.Fatal(err) } } // A — undeployed-but-toggled app whose unit lives on a REGISTERED drive (not systemDataPath): discovery // must find it there. The harness has no stackProvider, so the OLD AppNamespaceRoot path would resolve to // systemDataPath (where no unit is) — the F1 bug. See the companion red-proof in REPORT. func TestOffbox_DiscoversUnitOnRegisteredDrive(t *testing.T) { m, sett := newOffboxManager(t) usb := t.TempDir() addSchedulablePath(t, sett, usb) usbNS := m.namespaceRoot(usb) // registered drive → in-guest → nsRoot == usb writeUnit(t, usbNS, "audiobookshelf", "2026-07-01T00:00:00Z") // prove the OLD resolution would have looked elsewhere (no unit at systemDataPath nsRoot) if _, err := os.Stat(RecoveryUnitPath(m.namespaceRoot(m.systemDataPath), "audiobookshelf")); err == nil { t.Fatal("setup: unit must NOT exist on systemDataPath for this repro") } _ = sett.SetAppOffbox("audiobookshelf", true) rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } wantSrc := RecoveryUnitPath(usbNS, "audiobookshelf") if len(rr.backupSrc) != 1 || rr.backupSrc[0] != wantSrc { t.Fatalf("backup src = %v, want exactly [%s] (discovered on the registered drive)", rr.backupSrc, wantSrc) } if st := sett.GetOffboxTarget(); st.LastStatus != "ok" || st.LastWarning != "" { t.Fatalf("status = %+v, want ok / no warning", st) } } // B — a toggled app with NO recovery unit anywhere: the run must be a HARD ERROR (no silent ok/0), alert // the operator, and record status=error naming the app. Companion red-proof in REPORT. func TestOffbox_NoUnitAnywhereIsHardError(t *testing.T) { m, sett := newOffboxManager(t) _ = sett.SetAppOffbox("ghost", true) // toggled; no unit created anywhere rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) var notified bool var notifiedErr error m.SetOffboxNotify(func(_ time.Duration, _ int, err error) { notified = true; notifiedErr = err }) err := m.RunOffboxBackup(context.Background()) if err == nil { t.Fatal("0-of-N toggled apps backed up must ERROR (no silent success)") } if len(rr.backupSrc) != 0 { t.Fatalf("no backup should have run, got %v", rr.backupSrc) } if !notified || notifiedErr == nil { t.Fatal("the 0/N run must alert the operator with a non-nil err") } st := sett.GetOffboxTarget() if st.LastStatus != "error" || st.LastError == "" { t.Fatalf("status must be error, got %+v", st) } if !strings.Contains(st.LastError, "ghost") { t.Fatalf("LastError should name the missing app, got %q", st.LastError) } } // C — partial: one toggled app has a unit, another doesn't → the present one is backed up, status stays // ok, notify err is nil, but LastWarning (Hungarian) names the missing app. func TestOffbox_PartialRunWarnsNotErrors(t *testing.T) { m, sett := newOffboxManager(t) usb := t.TempDir() addSchedulablePath(t, sett, usb) usbNS := m.namespaceRoot(usb) writeUnit(t, usbNS, "present", "2026-07-01T00:00:00Z") _ = sett.SetAppOffbox("present", true) _ = sett.SetAppOffbox("gone", true) // no unit rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) notifiedErr := errors.New("sentinel") // must be cleared to nil by a non-erroring run m.SetOffboxNotify(func(_ time.Duration, _ int, err error) { notifiedErr = err }) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("a partial run must NOT error, got %v", err) } if len(rr.backupSrc) != 1 || rr.backupSrc[0] != RecoveryUnitPath(usbNS, "present") { t.Fatalf("only 'present' should be backed up, got %v", rr.backupSrc) } if notifiedErr != nil { t.Fatalf("partial run must notify with nil err, got %v", notifiedErr) } st := sett.GetOffboxTarget() if st.LastStatus != "ok" { t.Fatalf("status = %q, want ok", st.LastStatus) } if !strings.Contains(st.LastWarning, "gone") { t.Fatalf("LastWarning must name the missing app 'gone', got %q", st.LastWarning) } } // D — the same app's unit on TWO registered drives (drive churn): exactly ONE backup call, for the NEWER // unit (by manifest CreatedAt). func TestOffbox_MultipleUnitsPicksNewest(t *testing.T) { m, sett := newOffboxManager(t) older, newer := t.TempDir(), t.TempDir() addSchedulablePath(t, sett, older) addSchedulablePath(t, sett, newer) olderNS, newerNS := m.namespaceRoot(older), m.namespaceRoot(newer) writeUnit(t, olderNS, "z", "2026-01-01T00:00:00Z") writeUnit(t, newerNS, "z", "2026-07-01T00:00:00Z") _ = sett.SetAppOffbox("z", true) rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } wantSrc := RecoveryUnitPath(newerNS, "z") if len(rr.backupSrc) != 1 || rr.backupSrc[0] != wantSrc { t.Fatalf("must back up exactly the NEWER unit once; got %v want [%s]", rr.backupSrc, wantSrc) } } // E — every toggled app present: all backed up, status ok, no warning, snapshot count reflects stats. func TestOffbox_AllPresentHappyPath(t *testing.T) { m, sett := newOffboxManager(t) usb := t.TempDir() addSchedulablePath(t, sett, usb) usbNS := m.namespaceRoot(usb) writeUnit(t, usbNS, "a", "2026-07-01T00:00:00Z") writeUnit(t, usbNS, "b", "2026-07-01T00:00:00Z") _ = sett.SetAppOffbox("a", true) _ = sett.SetAppOffbox("b", true) rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } if len(rr.backupSrc) != 2 { t.Fatalf("both apps must be backed up, got %v", rr.backupSrc) } st := sett.GetOffboxTarget() if st.LastStatus != "ok" || st.LastWarning != "" { t.Fatalf("happy path wants ok + no warning, got %+v", st) } if st.SnapshotCount != 1 { t.Fatalf("snapshot count should reflect the stats fake (1), got %d", st.SnapshotCount) } } // Edge — zero toggled apps is a clean no-op ok (no error, no backup call). func TestOffbox_NoAppsToggledIsCleanOK(t *testing.T) { m, sett := newOffboxManager(t) rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("zero toggled apps must be a clean no-op, got %v", err) } if len(rr.backupSrc) != 0 { t.Fatalf("no backup should run with 0 toggled apps, got %v", rr.backupSrc) } if st := sett.GetOffboxTarget(); st.LastStatus != "ok" || st.LastError != "" { t.Fatalf("status = %+v, want ok / no error", st) } } // --- fork-4: atomicity gate + DR inject + coord --- // setPending overrides the harness's escrowed default to pending. func setEscrowState(t *testing.T, sett *settings.Settings, state string) { t.Helper() if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = state }); err != nil { t.Fatal(err) } } // Scenario A — a toggled app with a present unit is NOT backed up while escrow is pending (atomicity). func TestOffbox_PendingEscrowBlocksRun(t *testing.T) { m, sett := newOffboxManager(t) setEscrowState(t, sett, "pending") usb := t.TempDir() addSchedulablePath(t, sett, usb) writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z") _ = sett.SetAppOffbox("app1", true) rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("pending escrow must be a clean skip, got %v", err) } if len(rr.backupSrc) != 0 { t.Fatalf("NO offsite backup may run while escrow is pending, got %v", rr.backupSrc) } if !m.OffboxConfigured() { t.Fatal("config must still be valid while pending (only RUNS are gated)") } if m.OffboxRunnable() { t.Fatal("OffboxRunnable must be false while pending") } } // Scenario B — confirming escrow flips to escrowed and the run then proceeds. func TestOffbox_ConfirmEscrowEnablesRun(t *testing.T) { m, sett := newOffboxManager(t) setEscrowState(t, sett, "pending") usb := t.TempDir() addSchedulablePath(t, sett, usb) writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z") _ = sett.SetAppOffbox("app1", true) rr := &recordingOffboxRunner{} m.SetOffboxRunner(rr.run) if err := m.RunOffboxBackup(context.Background()); err != nil || len(rr.backupSrc) != 0 { t.Fatalf("must be blocked while pending (err=%v src=%v)", err, rr.backupSrc) } setEscrowState(t, sett, "escrowed") if !m.OffboxRunnable() { t.Fatal("must be runnable after confirm") } if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run after confirm: %v", err) } if len(rr.backupSrc) != 1 { t.Fatalf("must back up after confirm, got %v", rr.backupSrc) } } // Scenario C — a pre-placed (DR-injected) recovered password is honored, not regenerated. func TestOffbox_InjectPasswordPrePlaced(t *testing.T) { m, _ := newOffboxManager(t) _ = os.Remove(m.offboxPwPath()) // simulate a fresh controller (no password yet) const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" if err := m.InjectOffboxPassword(recovered, false); err != nil { t.Fatalf("inject: %v", err) } if err := m.WriteOffboxSecrets("newkey", "nas.local ssh-ed25519 NEWKEY"); err != nil { t.Fatal(err) } got, _ := os.ReadFile(m.offboxPwPath()) if string(got) != recovered { t.Fatalf("injected password was overwritten (len now %d) — the existing repo would be unopenable", len(got)) } // refuse to clobber an existing password without force if err := m.InjectOffboxPassword("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", false); err == nil { t.Fatal("inject must refuse to clobber an existing password without force") } // force overwrites const forced = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" if err := m.InjectOffboxPassword(forced, true); err != nil { t.Fatalf("force inject: %v", err) } if got2, _ := os.ReadFile(m.offboxPwPath()); string(got2) != forced { t.Fatal("force inject must overwrite") } // invalid (non-hex / wrong length) rejected if err := m.InjectOffboxPassword("not-a-valid-hex-password", false); err == nil { t.Fatal("an invalid repo password must be rejected") } } // Companion to C — WITHOUT inject, WriteOffboxSecrets generates a DIFFERENT password (so the recovered // one is load-bearing: a fresh gen could never open the existing offsite repo). func TestOffbox_NoInjectGeneratesDifferentPassword(t *testing.T) { m, _ := newOffboxManager(t) _ = os.Remove(m.offboxPwPath()) const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" if err := m.WriteOffboxSecrets("k", "kh"); err != nil { // no inject → generates fresh t.Fatal(err) } raw, rerr := os.ReadFile(m.offboxPwPath()) if rerr != nil { t.Fatal(rerr) } got := strings.TrimSpace(string(raw)) if got == recovered { t.Fatal("the freshly generated password coincided with the recovered one (impossible with 256-bit entropy)") } if len(got) != 64 { t.Fatalf("generated repo password must be 64 hex, got %d", len(got)) } } // Scenario E (backup half) — OffboxCoord returns the non-secret coordinates, ok=false when unconfigured. func TestOffbox_CoordForDR(t *testing.T) { m, sett := newOffboxManager(t) host, user, port, repo, ok := m.OffboxCoord() if !ok || host != "nas.local" || user != "felhom" || port != 22 || repo != "/srv/repo" { t.Fatalf("coord = %s/%s/%d/%s ok=%v", host, user, port, repo, ok) } if err := sett.SetOffboxTarget(&settings.OffboxTarget{}); err != nil { // empty target t.Fatal(err) } if _, _, _, _, ok := m.OffboxCoord(); ok { t.Fatal("an unconfigured target must yield ok=false") } } func runtimeIsUnix() bool { return os.PathSeparator == '/' } func contains(ss []string, want string) bool { for _, s := range ss { if s == want { return true } } return false } // tagOf returns the LAST --tag value (the per-stack tag; backup adds "felhom-offbox" then the stack). func tagOf(args []string) string { tag := "" for i, a := range args { if a == "--tag" && i+1 < len(args) { tag = args[i+1] } } return tag } func valAfter(args []string, flag string) string { for i, a := range args { if a == flag && i+1 < len(args) { return args[i+1] } } return "" } func copyTree(src, dst string) error { return filepath.Walk(src, func(p string, info os.FileInfo, err error) error { if err != nil { return err } rel, _ := filepath.Rel(src, p) target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, 0o755) } b, rerr := os.ReadFile(p) if rerr != nil { return rerr } return os.WriteFile(target, b, 0o644) }) }