7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
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)
|
|
}
|
|
}
|