27b373b93c
POST /backup/offbox/clear (reveal-then-confirm) forgets the target and deletes ssh_key + known_hosts; nothing on the target is touched. repo_password is kept whenever anything could depend on it (hub sealed package — the R-241 rule; escrowed; a successful run; snapshots) and deleted only otherwise. Refused for the Felhom tier (rclone-pinned), while the single-flight is held, and while an abandonment countdown runs. i18n parity fixtures of backups_remote gain the additive block only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
161 lines
5.8 KiB
Go
161 lines
5.8 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// R-729 / R-545 — the household can remove its own off-site target; the repository is never touched,
|
|
// and the repository password is kept whenever anything could depend on it. See offbox_clear.go.
|
|
|
|
func clearFixture(t *testing.T, hubHoldsPackage bool, tgt *settings.OffboxTarget) (*Manager, *settings.Settings, string) {
|
|
t.Helper()
|
|
m, sett, pwPath := mintGuardManager(t, false) // mint first, THEN set the package fact
|
|
if err := sett.SetOffboxTarget(tgt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Any remote command would go through the runner — none may run.
|
|
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
|
|
t.Errorf("R-729: clearing a target ran restic %v — the repository must never be touched", args)
|
|
return nil, errors.New("forbidden")
|
|
})
|
|
m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
|
|
t.Errorf("R-729: clearing a target ran a remote command %q — the repository must never be touched", remoteCmd)
|
|
return nil, errors.New("forbidden")
|
|
})
|
|
return m, sett, filepath.Dir(pwPath)
|
|
}
|
|
|
|
func ownNAS() *settings.OffboxTarget {
|
|
return &settings.OffboxTarget{Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", EscrowState: "pending"}
|
|
}
|
|
|
|
// The headline: a never-used own-NAS target is forgotten and every secret it minted is gone.
|
|
func TestR729_Clear_ForgetsTargetAndDeletesSecrets(t *testing.T) {
|
|
m, sett, dir := clearFixture(t, false, ownNAS())
|
|
res, err := m.ClearOffboxTarget()
|
|
if err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if sett.GetOffboxTarget() != nil {
|
|
t.Fatal("R-729: the target is still in settings after the clear")
|
|
}
|
|
for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} {
|
|
if exists(filepath.Join(dir, f)) {
|
|
t.Errorf("R-545: %s is still on disk after clearing a target nothing depends on", f)
|
|
}
|
|
}
|
|
if res.KeptRepoPassword {
|
|
t.Error("KeptRepoPassword=true for a target with no escrow, no history and no hub package")
|
|
}
|
|
// Idempotent refusal: nothing left to clear.
|
|
if _, err := m.ClearOffboxTarget(); !errors.Is(err, ErrOffboxClearNoTarget) {
|
|
t.Errorf("second clear: want ErrOffboxClearNoTarget, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The password is the one secret that may protect history — kept whenever anything depends on it.
|
|
func TestR729_Clear_KeepsRepoPasswordWhenSomethingDependsOnIt(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
pkg bool
|
|
mutate func(*settings.OffboxTarget)
|
|
}{
|
|
{"hub holds a sealed package (R-241)", true, func(*settings.OffboxTarget) {}},
|
|
{"escrow confirmed", false, func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }},
|
|
{"a run succeeded once", false, func(o *settings.OffboxTarget) { o.LastSuccess = "2026-10-01T02:00:00Z" }},
|
|
{"snapshots exist", false, func(o *settings.OffboxTarget) { o.SnapshotCount = 3 }},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
tgt := ownNAS()
|
|
c.mutate(tgt)
|
|
m, sett, dir := clearFixture(t, c.pkg, tgt)
|
|
res, err := m.ClearOffboxTarget()
|
|
if err != nil {
|
|
t.Fatalf("clear: %v", err)
|
|
}
|
|
if !exists(filepath.Join(dir, "repo_password")) {
|
|
t.Fatal("R-241: the repository password was deleted although something depends on it")
|
|
}
|
|
if !res.KeptRepoPassword {
|
|
t.Error("KeptRepoPassword=false although the key was kept")
|
|
}
|
|
if exists(filepath.Join(dir, "ssh_key")) || exists(filepath.Join(dir, "known_hosts")) {
|
|
t.Error("the transport secrets must still be deleted")
|
|
}
|
|
if sett.GetOffboxTarget() != nil {
|
|
t.Error("the target must still be forgotten")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Refusals leave every file and the setting exactly as they were.
|
|
func TestR729_Clear_Refusals(t *testing.T) {
|
|
check := func(t *testing.T, m *Manager, sett *settings.Settings, dir string, want error) {
|
|
t.Helper()
|
|
if _, err := m.ClearOffboxTarget(); !errors.Is(err, want) {
|
|
t.Fatalf("want %v, got %v", want, err)
|
|
}
|
|
if sett.GetOffboxTarget() == nil {
|
|
t.Error("a refused clear removed the target")
|
|
}
|
|
for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} {
|
|
if !exists(filepath.Join(dir, f)) {
|
|
t.Errorf("a refused clear deleted %s", f)
|
|
}
|
|
}
|
|
}
|
|
t.Run("hub tier", func(t *testing.T) {
|
|
tgt := ownNAS()
|
|
tgt.Transport = settings.TransportRclonePinned
|
|
m, sett, dir := clearFixture(t, false, tgt)
|
|
check(t, m, sett, dir, ErrOffboxClearHubTier)
|
|
})
|
|
t.Run("run in flight", func(t *testing.T) {
|
|
m, sett, dir := clearFixture(t, false, ownNAS())
|
|
if err := m.acquireRunning(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer m.releaseRunning()
|
|
check(t, m, sett, dir, ErrOffboxClearBusy)
|
|
})
|
|
t.Run("abandonment countdown", func(t *testing.T) {
|
|
tgt := ownNAS()
|
|
tgt.AbandonStartedAt = time.Now().UTC().Format(time.RFC3339)
|
|
tgt.AbandonAt = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
|
tgt.AbandonRepoPath = "/srv/repo.orphaned-1"
|
|
m, sett, dir := clearFixture(t, false, tgt)
|
|
check(t, m, sett, dir, ErrOffboxClearAbandonOn)
|
|
})
|
|
t.Run("hub-held deletion", func(t *testing.T) {
|
|
tgt := ownNAS()
|
|
tgt.AbandonHubDueAt = time.Now().Add(3 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
|
m, sett, dir := clearFixture(t, false, tgt)
|
|
check(t, m, sett, dir, ErrOffboxClearAbandonOn)
|
|
})
|
|
}
|
|
|
|
// The single-flight is released after a clear — a later run is not wedged.
|
|
func TestR729_Clear_ReleasesSingleFlight(t *testing.T) {
|
|
m, _, _ := clearFixture(t, false, ownNAS())
|
|
if _, err := m.ClearOffboxTarget(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.IsRunning() {
|
|
t.Fatal("the clear left the single-flight held")
|
|
}
|
|
}
|