Files
felhom-controller/controller/internal/backup/offbox_shares_test.go
T
admin 85b76e0fc3 feat(shares): R-7b Part 3 — offsite shares leg (Model B') + B' isolation proof
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
2026-07-18 12:51:46 +02:00

260 lines
9.6 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"
"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
}