Files
felhom-controller/controller/internal/backup/offbox_test.go
T
admin 0b09a799cb v0.105.0: fork-4 offsite password custody — hand-off + atomicity gate + DR inject + coord
Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the
agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending".
Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until
EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the
escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR:
POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600
(honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without
force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP
key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState.
Tests + atomicity & inject companion red-proofs green; UI gates pass. NOT yet
live-validated (supervised ceremony).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-09 15:13:59 +02:00

679 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 AE) ---
// 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 <nsRoot>/backups/primary/<app>.
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)
})
}