3c49dc8ea4
gates / gates (push) Successful in 12s
R-399: monitoring.integrity.read_data_subset defaults to 100%. A pack damaged without changing its size made plain `restic check` report "no errors were found" on demo-hp 2026-08-30; every read-data form caught it. Cost on that 134 MB store: 35.0s structure vs 39.2s at 100%. "off" (any case) is the off token; empty means not-configured, therefore the default; a malformed value falls back to the DEFAULT, never to structure. A completed check over 5 minutes logs a WARN naming the duration, the depth and R-401 — operator log only, no hub event, no depth change. The depth is now recorded with the verdict (LastIntegrityDepth; empty = NOT RECORDED, never "structure"). R-400: 24 debug-page references, 17 dispatched, 7 dead — three of which fetched on page LOAD, so those panels were permanently blank. backup/crossdrive implemented; backup/infra, hub/infra-push, dr/infra-status, storage/watchdog-status and both storage/simulate-* deleted with their panels and JavaScript. scripts/debug_route_gate.py fails in both directions and is registered after the seven were resolved. 18 referenced, 18 dispatched, none orphaned. Corrections: the dead-field warning in report/types.go said the controller runs no integrity check and the notifiers are called from nowhere — both false since v0.227.0. controller.yaml.example gains its missing integrity: block. integrityCheckTimeout's "ships OFF" comment rewritten.
289 lines
12 KiB
Go
289 lines
12 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// ── R-359 — the off-site store was never checked ─────────────────────────────────────────────────
|
|
//
|
|
// The whole-guest tier has verify jobs; the tier holding the customer's documents and photos had none.
|
|
// The complete set of restic verbs this controller used contained no `check` — verified 2026-08-30.
|
|
//
|
|
// These drive the REAL CheckOffboxIntegrity through the EXISTING `offboxRunner` seam, which has been
|
|
// injectable since the off-site tier shipped. (R-398 claimed otherwise and was my own mistake; the
|
|
// seam sees every argv, including the `unlock --remove-all` escalation a `resticStepFn` would have
|
|
// hidden — which is exactly what the lock-safety tests must observe.)
|
|
|
|
// errFake is a plain non-nil error for seam replies; the classifier reads the OUTPUT, not the error
|
|
// type, so a synthetic error is faithful here.
|
|
var errFake = errors.New("restic exited non-zero")
|
|
|
|
// integrityCapture records every restic invocation so both the effects and the NON-effects are
|
|
// assertable. `argvs` is the whole point: a test that only checks the verdict cannot tell a check that
|
|
// ran from one that did not.
|
|
type integrityCapture struct {
|
|
argvs [][]string
|
|
reply func(args []string) ([]byte, error)
|
|
logBuf *bytes.Buffer
|
|
}
|
|
|
|
func (c *integrityCapture) runner() offboxRunner {
|
|
return func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
c.argvs = append(c.argvs, append([]string{}, args...))
|
|
if c.reply != nil {
|
|
return c.reply(args)
|
|
}
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func (c *integrityCapture) sawVerb(verb string) bool {
|
|
for _, a := range c.argvs {
|
|
for _, x := range a {
|
|
if x == verb {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *integrityCapture) checkArgv() []string {
|
|
for _, a := range c.argvs {
|
|
for _, x := range a {
|
|
if x == "check" {
|
|
return a
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// newIntegrityManager builds a manager with a configured off-site target and a captured runner.
|
|
func newIntegrityManager(t *testing.T, reply func(args []string) ([]byte, error)) (*Manager, *integrityCapture) {
|
|
t.Helper()
|
|
m, _ := newOffboxManager(t)
|
|
cap := &integrityCapture{reply: reply, logBuf: &bytes.Buffer{}}
|
|
m.logger = log.New(cap.logBuf, "", 0)
|
|
m.SetOffboxRunner(cap.runner())
|
|
return m, cap
|
|
}
|
|
|
|
// okRepo answers `cat config` so ensureOffboxRepo passes, then defers to `then` for everything else.
|
|
func okRepo(then func(args []string) ([]byte, error)) func(args []string) ([]byte, error) {
|
|
return func(args []string) ([]byte, error) {
|
|
for _, a := range args {
|
|
if a == "config" {
|
|
return []byte(`{"version":2}`), nil
|
|
}
|
|
}
|
|
if then != nil {
|
|
return then(args)
|
|
}
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func TestR359_HealthyRepoReportsOK(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if !res.OK || res.Skipped || res.Unreachable {
|
|
t.Fatalf("a healthy repo did not report OK: %+v", res)
|
|
}
|
|
if cap.checkArgv() == nil {
|
|
t.Fatal("`restic check` was never invoked — the check did not check anything")
|
|
}
|
|
}
|
|
|
|
func TestR359_RepositoryErrorReportsFailure(t *testing.T) {
|
|
// restic's own words from the 2026-08-21 damaged-pack drill.
|
|
const damaged = "pack 5b1f2c3d: not found in index\nrepository contains errors"
|
|
m, _ := newIntegrityManager(t, okRepo(func(args []string) ([]byte, error) {
|
|
return []byte(damaged), errFake
|
|
}))
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if res.OK {
|
|
t.Fatal("a repository restic said contains errors was reported as OK — this is the defect the " +
|
|
"whole feature exists to prevent")
|
|
}
|
|
if res.Unreachable {
|
|
t.Fatal("readable-and-damaged was misclassified as unreachable — those are different facts, " +
|
|
"and only one of them means the customer's backups are broken")
|
|
}
|
|
if !strings.Contains(res.Output, "not found in index") {
|
|
t.Errorf("restic's own words must reach the LOG so the operator can diagnose; got %q", res.Output)
|
|
}
|
|
}
|
|
|
|
func TestR359_UnreachableIsNotAnIntegrityFailure(t *testing.T) {
|
|
// The repo cannot even be opened. "I could not look" is not "I looked and it is broken".
|
|
m, _ := newIntegrityManager(t, func(args []string) ([]byte, error) {
|
|
return []byte("ssh: connect to host nas.local port 22: Connection refused"), errors.New("exit 1")
|
|
})
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if res.OK {
|
|
t.Fatal("an unreachable repository was reported as a passing check")
|
|
}
|
|
if !res.Unreachable {
|
|
t.Fatal("an unreachable repository was reported as DAMAGE — that would alarm the customer that " +
|
|
"their backups are corrupt when nothing was ever looked at, and R-339 already owns reachability")
|
|
}
|
|
}
|
|
|
|
func TestR359_TimeoutIsNotDamage(t *testing.T) {
|
|
m, _ := newIntegrityManager(t, okRepo(func(args []string) ([]byte, error) {
|
|
return nil, context.DeadlineExceeded
|
|
}))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // an already-dead context: the check cannot finish
|
|
res := m.CheckOffboxIntegrity(ctx)
|
|
|
|
if res.OK {
|
|
t.Fatal("a check that never finished reported OK")
|
|
}
|
|
if !res.Unreachable {
|
|
t.Fatalf("a check that timed out was reported as damage: %+v — it saw nothing, so it may not "+
|
|
"claim the store is broken", res)
|
|
}
|
|
}
|
|
|
|
// TestR359_StructureCheckPassesNoReadDataFlag was DELETED on 2026-08-31, superseded by R-399.
|
|
//
|
|
// It asserted that an unconfigured box passes NO --read-data flag. That was the correct contract on
|
|
// 2026-08-30, when nothing had measured the cost of a deeper check. The next day a size-preserving
|
|
// pack corruption was shown to PASS that structure-only check on real hardware, and Viktor ruled the
|
|
// default to full depth. The test is not weakened, it is inverted: its replacement is
|
|
// TestR399_AbsentConfigRunsFullDepth in r399_depth_test.go, and the off token it left room for is
|
|
// pinned by TestR399_OffTokenRunsStructureOnly.
|
|
//
|
|
// Recorded here rather than removed silently, so a later reader does not re-derive the old ruling
|
|
// from its absence.
|
|
|
|
func TestR359_ReadDataSubsetIsPassedWhenConfigured(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
m.cfg.Monitoring.Integrity.ReadDataSubset = "5%"
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if res.ReadDataSubset != "5%" {
|
|
t.Errorf("result did not record the depth it ran at: %+v", res)
|
|
}
|
|
var found bool
|
|
for _, a := range cap.checkArgv() {
|
|
if a == "--read-data-subset=5%" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("the configured subset did not reach restic; argv=%v", cap.checkArgv())
|
|
}
|
|
}
|
|
|
|
// TestR359_MalformedReadDataSubsetIsTreatedAsOff was DELETED on 2026-08-31, superseded by R-399.
|
|
//
|
|
// Its NAME was the defect. Treating a typo as "off" downgrades the check silently, which is R-357's
|
|
// shape — a guard that opens quietly. The half of it that still holds (a malformed value never
|
|
// reaches restic, and it WARNs) is asserted by TestR399_MalformedFallsBackToTheDefault, which also
|
|
// pins the new direction: the fallback is the DEFAULT depth, never structure.
|
|
|
|
func TestR359_MessageNeverCarriesResticOutputOrCredentials(t *testing.T) {
|
|
// R-379: 615 bytes of raw database text reached a customer once. And offboxBaseArgs builds the repo
|
|
// as `sftp:<user>@<host>:<path>`, so a raw passthrough leaks the credential shape too.
|
|
const secretish = "sftp:felhom@nas.local:/srv/repo pack 5b1f2c3d corrupt"
|
|
m, _ := newIntegrityManager(t, okRepo(func(args []string) ([]byte, error) {
|
|
return []byte(secretish), errFake
|
|
}))
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if res.OK {
|
|
t.Fatal("fixture wrong: this should be a failure")
|
|
}
|
|
// The customer sentence is a CONSTANT and contains none of it. Asserted here rather than only in
|
|
// the web package because this is where the output is captured.
|
|
for _, bad := range []string{"sftp:", "nas.local", "5b1f2c3d", "felhom@"} {
|
|
if strings.Contains(integrityFailedCustomerSentence, bad) {
|
|
t.Fatalf("the customer-facing failure sentence carries %q", bad)
|
|
}
|
|
}
|
|
// ...while the operator's log DOES get it, or the fault cannot be diagnosed without a rebuild.
|
|
if !strings.Contains(res.Output, "5b1f2c3d") {
|
|
t.Error("restic's output did not reach the result for the log")
|
|
}
|
|
}
|
|
|
|
// integrityFailedCustomerSentence mirrors the constant in cmd/controller. Duplicated deliberately and
|
|
// narrowly: this package cannot import main, and the property under test is that the SENTENCE carries
|
|
// no machine detail — a property of the words themselves.
|
|
const integrityFailedCustomerSentence = "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot."
|
|
|
|
func TestR359_NoTargetConfiguredIsASilentSkip(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
if err := m.settings.SetOffboxTarget(&settings.OffboxTarget{Enabled: false}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if !res.Skipped {
|
|
t.Fatalf("a box with no off-site tier did not skip: %+v", res)
|
|
}
|
|
if len(cap.argvs) != 0 {
|
|
t.Fatalf("restic ran on a box with no off-site target: %v", cap.argvs)
|
|
}
|
|
if res.OK {
|
|
t.Fatal("a skip was reported as a passing check — nothing was checked")
|
|
}
|
|
}
|
|
|
|
// TestR359_RealResticDamageOutputIsClassifiedAsDamage uses the EXACT bytes restic produced on
|
|
// `demo-hp` on 2026-08-30 against a deliberately corrupted throwaway repository (Part 5's positive
|
|
// control). Invented output would only prove the classifier agrees with my guess about restic; this
|
|
// closes the loop on real bytes.
|
|
//
|
|
// The damage was 64 zero bytes written at offset 1024 of one pack, leaving the file SIZE unchanged —
|
|
// the subtlest form, and the one a structure check cannot see. See the accompanying finding: plain
|
|
// `restic check` returned "no errors were found" and exit 0 over this very repository.
|
|
func TestR359_RealResticDamageOutputIsClassifiedAsDamage(t *testing.T) {
|
|
const realOutput = "Pack ID does not match, want 288afd3e868dc6bd210e33bd6f821e9f088a5fd71a0464c23ce82eb8217bf0cc, got 4b6847bb5eece6c56e69d7381733827330a4eb799fc26a92287a181edc496d2b\nFatal: repository contains errors"
|
|
|
|
if !looksLikeRepositoryDamage([]byte(realOutput)) {
|
|
t.Fatal("restic's REAL damage output was not recognised as damage — the check would report a " +
|
|
"corrupted store as merely unreachable, and the customer would never be told")
|
|
}
|
|
|
|
m, _ := newIntegrityManager(t, okRepo(func([]string) ([]byte, error) {
|
|
return []byte(realOutput), errFake
|
|
}))
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if res.OK {
|
|
t.Fatal("a repository restic called corrupt was reported as passing")
|
|
}
|
|
if res.Unreachable {
|
|
t.Fatal("readable-and-corrupt was reported as unreachable — the store WAS opened and read; " +
|
|
"that misclassification would suppress the one alarm that matters")
|
|
}
|
|
if !strings.Contains(res.Output, "288afd3e") {
|
|
t.Error("restic's own words did not reach the log")
|
|
}
|
|
}
|
|
|
|
// TestR359_HealthyRealOutputIsNotDamage is the negative control for the classifier, from the same
|
|
// live run: the healthy repository's actual output must not trip the damage predicate.
|
|
func TestR359_HealthyRealOutputIsNotDamage(t *testing.T) {
|
|
const realHealthy = "using temporary cache in /tmp/restic-check-cache-962728151\ncreate exclusive lock for repository\nload indexes\ncheck all packs\ncheck snapshots, trees and blobs\n\nno errors were found"
|
|
|
|
if looksLikeRepositoryDamage([]byte(realHealthy)) {
|
|
t.Fatalf("a HEALTHY check's real output was classified as damage — every weekly check would " +
|
|
"alarm, which is how an operator learns to ignore the alarm")
|
|
}
|
|
}
|