85b76e0fc3
ONE additional restic call tagged [felhom-offbox, _shares] carrying the payload staging dir + every mandatory share folder. Hooked into runOffboxInternal AFTER the per-app loop and BEFORE retention, so forget --group-by host,tags covers the _shares group with no flag change. Reuses resticStep, the caller's repo-ensure and single-flight, and the SAME enlargement-gate arithmetic. - quota gate degrades the push to MANIFEST-ONLY, never to nothing - EnlargedBlocked keeps the RAW _shares key (templates index by it); the display mapping applies only at the notification + Hungarian-prose boundaries - OffboxTarget gains SharesLastRun/Status/Count for per-tier page truth - zero-toggle notice suppressed when the shares leg provided coverage - reserved-name defense: an app keyed _shares is excluded from the run loudly RED-PROOFS RUN AND REVERTED (all fired): 1. shares leg appends into the app's argv -> isolation test FAILS 2. mandatory->offsite mapping inverted -> Scenario A + B FAIL 3. manifest-only degradation dropped -> Scenario C FAILS
260 lines
9.6 KiB
Go
260 lines
9.6 KiB
Go
package backup
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||
)
|
||
|
||
// R-7b offsite shares leg. The headline test in this file is the B′ ISOLATION PROOF: adding the
|
||
// shares source must leave every per-app restic invocation BYTE-IDENTICAL. That is the whole premise
|
||
// of Model B′ — if it does not hold, the design has silently become engine-loop surgery.
|
||
|
||
// sharesOffboxEnv wires an offbox manager with one app (unit on `drive`) and the shares feature on,
|
||
// so a run can be taken with and without shares against the SAME paths.
|
||
type sharesOffboxEnv struct {
|
||
m *Manager
|
||
sett *settings.Settings
|
||
drive string
|
||
unit string
|
||
}
|
||
|
||
func newSharesOffboxEnv(t *testing.T, app string) *sharesOffboxEnv {
|
||
t.Helper()
|
||
drive := t.TempDir()
|
||
m, sett, prov := classifiedOffboxManager(t, drive)
|
||
unit := mkUnit(t, drive, app)
|
||
prov.hdd[app] = drive
|
||
if err := sett.SetAppOffbox(app, true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := sett.SetSMBEnabled(true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m.SetSharesPassdbCapturer(func() ([]byte, error) { return []byte("FAKE-PASSDB"), nil })
|
||
return &sharesOffboxEnv{m: m, sett: sett, drive: drive, unit: unit}
|
||
}
|
||
|
||
// addOffsiteShare registers an available share on the env's drive.
|
||
func (e *sharesOffboxEnv) addOffsiteShare(t *testing.T, name string, offsite bool) string {
|
||
t.Helper()
|
||
p := filepath.Join(e.drive, name)
|
||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// run takes one offsite run and returns the capture.
|
||
func (e *sharesOffboxEnv) run(t *testing.T) *backupCapture {
|
||
t.Helper()
|
||
cap := &backupCapture{}
|
||
e.m.SetOffboxRunner(cap.runner())
|
||
if err := e.m.RunOffboxBackup(context.Background()); err != nil {
|
||
t.Fatalf("run: %v", err)
|
||
}
|
||
return cap
|
||
}
|
||
|
||
// THE B′ ISOLATION PROOF. One app, then the same app plus one mandatory share: the app's restic argv
|
||
// must be byte-identical across both runs, and the shares source must appear as exactly ONE
|
||
// additional call. Red-proof: make the shares leg append its paths into the app's argv instead of
|
||
// issuing its own call — this test fails.
|
||
func TestOffboxSharesLegLeavesAppCallsByteIdentical(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
|
||
baseline := env.run(t)
|
||
baseArgs := baseline.byStack["immich"]
|
||
if len(baseArgs) == 0 {
|
||
t.Fatal("precondition: the baseline run produced no app backup call")
|
||
}
|
||
if baseline.backups != 1 {
|
||
t.Fatalf("precondition: baseline should be exactly 1 backup call, got %d", baseline.backups)
|
||
}
|
||
|
||
env.addOffsiteShare(t, "dokumentumok", true)
|
||
withShares := env.run(t)
|
||
|
||
gotArgs := withShares.byStack["immich"]
|
||
if strings.Join(gotArgs, "\x00") != strings.Join(baseArgs, "\x00") {
|
||
t.Errorf("B′ INVARIANT VIOLATED — the app's restic argv changed when shares were added:\n baseline: %v\n with shares: %v", baseArgs, gotArgs)
|
||
}
|
||
if withShares.backups != 2 {
|
||
t.Errorf("expected exactly ONE additional restic call for the shares source, got %d total", withShares.backups)
|
||
}
|
||
if _, ok := withShares.byStack[SharesPseudoStack]; !ok {
|
||
t.Fatalf("no restic call tagged %q was issued: %v", SharesPseudoStack, withShares.byStack)
|
||
}
|
||
}
|
||
|
||
// Scenario A: a mandatory share reaches offsite — correct tags, the manifest staging dir, and the
|
||
// share folder. Red-proof: flip the mandatory→offsite mapping (push only non-mandatory shares) and
|
||
// this fails.
|
||
func TestOffboxSharesLegPushesMandatoryShare(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
|
||
|
||
cap := env.run(t)
|
||
args := cap.byStack[SharesPseudoStack]
|
||
if len(args) == 0 {
|
||
t.Fatal("no shares call issued")
|
||
}
|
||
if !contains(args, "felhom-offbox") || !contains(args, SharesPseudoStack) {
|
||
t.Errorf("shares call must carry BOTH tags [felhom-offbox, %s]: %v", SharesPseudoStack, args)
|
||
}
|
||
if !contains(args, sharePath) {
|
||
t.Errorf("shares call missing the mandatory share path %q: %v", sharePath, args)
|
||
}
|
||
if !contains(args, env.m.SharesPayloadDir()) {
|
||
t.Errorf("shares call missing the manifest staging dir %q: %v", env.m.SharesPayloadDir(), args)
|
||
}
|
||
// The manifest on disk must be the registry.
|
||
blob, err := os.ReadFile(filepath.Join(env.m.SharesPayloadDir(), sharesManifestName))
|
||
if err != nil {
|
||
t.Fatalf("manifest not staged: %v", err)
|
||
}
|
||
if !strings.Contains(string(blob), "dokumentumok") {
|
||
t.Errorf("manifest does not describe the share: %s", blob)
|
||
}
|
||
// Per-tier status must be recorded for the „Megosztás" page.
|
||
_, status, count, ok := env.m.SharesOffsiteStatus()
|
||
if !ok || status != "ok" || count != 1 {
|
||
t.Errorf("SharesOffsiteStatus = (%q, %d, %v), want (ok, 1, true)", status, count, ok)
|
||
}
|
||
}
|
||
|
||
// Scenario B: an OPTIONAL share is tier-2-only — its path must appear in NO restic argument.
|
||
func TestOffboxSharesLegExcludesOptionalShare(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
mandatoryPath := env.addOffsiteShare(t, "dokumentumok", true)
|
||
optionalPath := env.addOffsiteShare(t, "filmek", false)
|
||
|
||
cap := env.run(t)
|
||
for tag, args := range cap.byStack {
|
||
if contains(args, optionalPath) {
|
||
t.Errorf("OPTIONAL share path leaked into the %q restic call: %v", tag, args)
|
||
}
|
||
}
|
||
if !contains(cap.byStack[SharesPseudoStack], mandatoryPath) {
|
||
t.Error("the mandatory share should still be pushed")
|
||
}
|
||
}
|
||
|
||
// Scenario C: the quota gate degrades the push to the MANIFEST ONLY — definitions protection never
|
||
// regresses — the blocked set gains the reserved key, and the notification is edge-triggered so a
|
||
// second identical run does NOT re-notify. Red-proof: drop the manifest-only degradation (skip the
|
||
// whole leg when blocked) and the "manifest still pushed" assertion fails.
|
||
func TestOffboxSharesLegQuotaDegradesToManifestOnly(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
|
||
|
||
// A 1 GB quota with a 2 GB share estimate: the gate must trip.
|
||
if err := env.sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 1 }); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
env.m.SetOffboxSizer(func(string) int64 { return 2 * offboxGiB })
|
||
|
||
var notified []string
|
||
env.m.SetOffboxEnlargeBlockedNotifier(func(stack string, _ int64, _, _ int) {
|
||
notified = append(notified, stack)
|
||
})
|
||
|
||
cap := env.run(t)
|
||
args := cap.byStack[SharesPseudoStack]
|
||
if len(args) == 0 {
|
||
t.Fatal("the blocked run must still push the definitions, not skip the leg entirely")
|
||
}
|
||
if contains(args, sharePath) {
|
||
t.Errorf("a quota-blocked push must NOT carry the share folder: %v", args)
|
||
}
|
||
if !contains(args, env.m.SharesPayloadDir()) {
|
||
t.Errorf("a quota-blocked push MUST still carry the manifest (definitions protection never regresses): %v", args)
|
||
}
|
||
// The persisted blocked set keeps the RAW key (templates index by it)…
|
||
tgt := env.sett.GetOffboxTarget()
|
||
if !containsStr(tgt.EnlargedBlocked, SharesPseudoStack) {
|
||
t.Errorf("EnlargedBlocked should contain the raw %q key, got %v", SharesPseudoStack, tgt.EnlargedBlocked)
|
||
}
|
||
// …while the NOTIFICATION boundary renders the Hungarian display name.
|
||
if len(notified) != 1 || notified[0] != SharesDisplayName {
|
||
t.Errorf("notification should fire once as %q, got %v", SharesDisplayName, notified)
|
||
}
|
||
// The customer-facing warning must not leak the reserved key either.
|
||
if strings.Contains(tgt.LastWarning, SharesPseudoStack) {
|
||
t.Errorf("the reserved key leaked into Hungarian prose: %q", tgt.LastWarning)
|
||
}
|
||
|
||
// Edge-trigger: an identical second run must NOT re-notify.
|
||
notified = nil
|
||
env.run(t)
|
||
if len(notified) != 0 {
|
||
t.Errorf("a persistently-blocked shares source must not re-notify nightly, got %v", notified)
|
||
}
|
||
}
|
||
|
||
// Sharing disabled / no shares: no `_shares` restic group is created at all.
|
||
func TestOffboxSharesLegNoOpWhenSharingOff(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
if err := env.sett.SetSMBEnabled(false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cap := env.run(t)
|
||
if _, ok := cap.byStack[SharesPseudoStack]; ok {
|
||
t.Error("a disabled sharing feature must create no _shares snapshot group")
|
||
}
|
||
if cap.backups != 1 {
|
||
t.Errorf("expected only the app's call, got %d", cap.backups)
|
||
}
|
||
}
|
||
|
||
// Scenario F, offsite side: a share on an unavailable drive reaches NO restic argument, and the run
|
||
// still covers the healthy shares.
|
||
func TestOffboxSharesLegSkipsDeadMount(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
live := env.addOffsiteShare(t, "elo", true)
|
||
dead := filepath.Join(env.drive, "halott")
|
||
if err := env.sett.AddSMBShare(settings.SMBShare{Name: "halott", Path: dead, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||
t.Fatal(err)
|
||
} // folder deliberately never created → unavailable
|
||
|
||
cap := env.run(t)
|
||
args := cap.byStack[SharesPseudoStack]
|
||
if contains(args, dead) {
|
||
t.Errorf("an unavailable share path reached the restic argv: %v", args)
|
||
}
|
||
if !contains(args, live) {
|
||
t.Errorf("the healthy share must still be pushed: %v", args)
|
||
}
|
||
}
|
||
|
||
// A run whose ONLY cloud content is shares must not be told "nothing is selected".
|
||
func TestOffboxSharesLegSuppressesZeroToggleNotice(t *testing.T) {
|
||
env := newSharesOffboxEnv(t, "immich")
|
||
if err := env.sett.SetAppOffbox("immich", false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
env.addOffsiteShare(t, "dokumentumok", true)
|
||
|
||
env.run(t)
|
||
if w := env.sett.GetOffboxTarget().LastWarning; strings.Contains(w, "nincs mentésre jelölt alkalmazás") {
|
||
t.Errorf("a box whose cloud content is its shares is covered — misleading warning: %q", w)
|
||
}
|
||
}
|
||
|
||
// containsStr is a small slice helper (the package's `contains` takes the restic argv shape).
|
||
func containsStr(hay []string, needle string) bool {
|
||
for _, h := range hay {
|
||
if h == needle {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|