5429d651ee
gates / gates (push) Successful in 11s
UnitRestoreDate also compared the package's date against the run's and flagged 'older'. A recovery unit is ALWAYS captured shortly before the run that mirrors it, so that comparison is true for every healthy app. Measured on demo-hp: bookstack, kimai, opengist and privatebin all had src and dest manifests at 12:03:49Z against a run at 12:14:24Z - perfectly healthy, and all four would have been told their package was stale. A warning that fires on everything is a warning nobody reads, which costs the same as the comforting lie it was meant to replace. The second return is now UnitLegPreserved and nothing else. TestR403_AHealthyAppIsNeverCalledStale pins it; red-proof: reinstate the comparison -> it fails.
172 lines
6.8 KiB
Go
172 lines
6.8 KiB
Go
package backup
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-403 Group A — the hollowness predicate.
|
|
//
|
|
// It decides whether a nightly copy is allowed to delete a customer's last package, so it is worth
|
|
// pinning precisely. Every case below is a shape that exists on a real box.
|
|
|
|
// r403Unit writes a recovery unit directory carrying the given dump lists, plus whatever extra files
|
|
// the caller asks for. The manifest is written by the PRODUCTION writeManifest, so the predicate and
|
|
// the capture meet at real bytes rather than at a hand-rolled JSON literal.
|
|
func r403Unit(t *testing.T, dbDumps, volDumps []string, extra map[string]string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "recovery-unit")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
man := &RecoveryManifest{SchemaVersion: 2, AppName: "app", DBDumps: dbDumps, VolumeDumps: volDumps}
|
|
if err := writeManifest(UnitManifestFile(dir), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for rel, body := range extra {
|
|
mustWrite(t, filepath.Join(dir, rel), body)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// A1 — a manifest listing neither kind of dump is HOLLOW. This is the exact shape measured on
|
|
// demo-hp: `"db_dumps": []`, `"volume_dumps": null`.
|
|
func TestR403_ManifestWithNoDumpsIsHollow(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
dbs, vols []string
|
|
}{
|
|
{"both empty slices", []string{}, []string{}},
|
|
{"both nil (the measured shape: db_dumps [] and volume_dumps null)", nil, nil},
|
|
{"empty db, nil volumes", []string{}, nil},
|
|
} {
|
|
dir := r403Unit(t, tc.dbs, tc.vols, nil)
|
|
if !unitIsHollow(dir) {
|
|
t.Errorf("%s: unitIsHollow()=false, want true", tc.name)
|
|
}
|
|
if unitCarriesData(dir) {
|
|
t.Errorf("%s: unitCarriesData()=true, want false", tc.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A2 — volume tars alone are enough. For the 45 class-B apps that archive is the entire dataset, so
|
|
// treating a volume-only unit as hollow would let the guard delete exactly the material it exists to
|
|
// protect.
|
|
func TestR403_ManifestWithVolumeDumpsOnlyIsNotHollow(t *testing.T) {
|
|
dir := r403Unit(t, nil, []string{"app_data.tar"}, nil)
|
|
if unitIsHollow(dir) {
|
|
t.Error("a unit carrying a volume tar was called hollow")
|
|
}
|
|
}
|
|
|
|
// A3 — a database dump alone is enough, for the same reason from the other side.
|
|
func TestR403_ManifestWithDBDumpsOnlyIsNotHollow(t *testing.T) {
|
|
dir := r403Unit(t, []string{"app-postgres.sql"}, nil, nil)
|
|
if unitIsHollow(dir) {
|
|
t.Error("a unit carrying a database dump was called hollow")
|
|
}
|
|
}
|
|
|
|
// A4 — an absent manifest is HOLLOW. FAIL CLOSED: a unit whose contents cannot be vouched for must
|
|
// never authorise a delete of one whose contents can.
|
|
func TestR403_AbsentManifestIsHollow(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "recovery-unit")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !unitIsHollow(dir) {
|
|
t.Error("a unit directory with no manifest was treated as data-bearing")
|
|
}
|
|
// And a directory that does not exist at all.
|
|
if !unitIsHollow(filepath.Join(t.TempDir(), "nope")) {
|
|
t.Error("an absent directory was treated as data-bearing")
|
|
}
|
|
}
|
|
|
|
// A5 — an unparseable manifest is HOLLOW, for the same fail-closed reason.
|
|
func TestR403_UnparseableManifestIsHollow(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "recovery-unit")
|
|
mustWrite(t, UnitManifestFile(dir), "{ this is not json")
|
|
if !unitIsHollow(dir) {
|
|
t.Error("a unit with an unparseable manifest was treated as data-bearing")
|
|
}
|
|
}
|
|
|
|
// A6 — TestR403_SizeIsNeverConsulted. THE DESIGN OF THE PREDICATE, as a test.
|
|
//
|
|
// A unit with a large compose tree and no dumps is DANGEROUS — it is exactly the shape that deleted
|
|
// 120 MB of dumps on demo-hp, and `dirSizeBytes` sits two files away and would call it substantial.
|
|
// A tiny unit belonging to a tiny app is FINE. Size answers "how big"; the question is "is there
|
|
// anything to recover", and only the manifest answers that.
|
|
//
|
|
// Red-proof (recorded in REPORT.md): switch `unitCarriesData` to a `dirSizeBytes` threshold and this
|
|
// test fails on the big-but-empty case.
|
|
func TestR403_SizeIsNeverConsulted(t *testing.T) {
|
|
// BIG and hollow: a fat compose capture, no dumps.
|
|
big := r403Unit(t, nil, nil, map[string]string{
|
|
"compose/docker-compose.yml": strings.Repeat("# padding\n", 20000),
|
|
"compose/app.yaml": strings.Repeat("# padding\n", 20000),
|
|
})
|
|
bigBytes := dirSizeBytes(big)
|
|
if bigBytes < 100000 {
|
|
t.Fatalf("fixture is not big enough to make the point: %d bytes", bigBytes)
|
|
}
|
|
if !unitIsHollow(big) {
|
|
t.Errorf("a %d-byte unit listing NO dumps was called data-bearing — size was consulted", bigBytes)
|
|
}
|
|
|
|
// TINY and data-bearing: one small dump, nothing else.
|
|
small := r403Unit(t, nil, []string{"v.tar"}, map[string]string{"volume-dumps/v.tar": "x"})
|
|
smallBytes := dirSizeBytes(small)
|
|
if unitIsHollow(small) {
|
|
t.Errorf("a %d-byte unit listing a volume tar was called hollow — size was consulted", smallBytes)
|
|
}
|
|
if smallBytes >= bigBytes {
|
|
t.Fatalf("fixture inverted: small=%d big=%d", smallBytes, bigBytes)
|
|
}
|
|
// The consequence stated plainly: the SMALLER unit is the one that carries data.
|
|
if !unitCarriesData(small) || unitCarriesData(big) {
|
|
t.Error("the predicate ordered these by size rather than by contents")
|
|
}
|
|
}
|
|
|
|
// TestR403_AHealthyAppIsNeverCalledStale — the bug the LIVE run caught, pinned.
|
|
//
|
|
// A recovery unit is always captured shortly BEFORE the Tier-2 run that mirrors it, so any test of
|
|
// the form "is the package older than the run?" is true for every healthy app. The first draft of
|
|
// UnitRestoreDate carried exactly that comparison, and on demo-hp 2026-08-31 four healthy apps
|
|
// (bookstack, kimai, opengist, privatebin — manifests at 12:03:49Z, run at 12:14:24Z) would each
|
|
// have told their owner the package was stale. Only `UnitLegPreserved` may raise it.
|
|
func TestR403_AHealthyAppIsNeverCalledStale(t *testing.T) {
|
|
healthy := Tier2Coverage{
|
|
UnitRestorable: true,
|
|
CopyLastSuccess: "2026-08-31T12:14:24Z", // the run
|
|
CopyLastRun: "2026-08-31T12:14:24Z",
|
|
UnitPackageDate: "2026-08-31T12:03:49Z", // the capture, minutes earlier — NORMAL
|
|
}
|
|
date, preserved := healthy.UnitRestoreDate()
|
|
if preserved {
|
|
t.Error("a healthy app whose package predates its run was flagged as preserved/stale — " +
|
|
"this fires for EVERY app and trains the customer to ignore the warning")
|
|
}
|
|
if date != "2026-08-31T12:03:49Z" {
|
|
t.Errorf("date = %q, want the package's own date", date)
|
|
}
|
|
|
|
// And the real case still raises it.
|
|
preservedCov := healthy
|
|
preservedCov.UnitLegPreserved = true
|
|
if _, p := preservedCov.UnitRestoreDate(); !p {
|
|
t.Error("a genuinely preserved package was NOT flagged")
|
|
}
|
|
|
|
// No package date recorded → fall back to the copy date, and still only flag on preservation.
|
|
noPkg := Tier2Coverage{CopyLastSuccess: "2026-08-31T12:14:24Z"}
|
|
if d, p := noPkg.UnitRestoreDate(); d != "2026-08-31T12:14:24Z" || p {
|
|
t.Errorf("fallback = (%q,%v), want the copy date and not-preserved", d, p)
|
|
}
|
|
}
|