package storage import ( "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) func TestVerifyClassifiesEveryCase(t *testing.T) { root := t.TempDir() src, dst := filepath.Join(root, "landing"), filepath.Join(root, "pool") for _, d := range []string{src, dst + "/a", src + "/a"} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } mtime := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) put := func(p, body string) LedgerEntry { if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } _ = os.Chtimes(p, mtime, mtime) sum := sha256.Sum256([]byte(body)) return LedgerEntry{Size: int64(len(body)), MTimeNs: mtime.UnixNano(), SHA256: hex.EncodeToString(sum[:])} } var ledger []LedgerEntry add := func(rel string, e LedgerEntry, at int64) { e.Path, e.T = rel, at; ledger = append(ledger, e) } add("a/match.bin", put(dst+"/a/match.bin", "intact"), 10) e := put(dst+"/a/mismatch.bin", "garbled") // same size and mtime as what was hashed, other bytes sum := sha256.Sum256([]byte("GARBLED")) e.SHA256 = hex.EncodeToString(sum[:]) add("a/mismatch.bin", e, 11) e = put(dst+"/a/changed.bin", "edited since, longer now") e.Size = 3 add("a/changed.bin", e, 12) add("a/gone.bin", LedgerEntry{Size: 4, MTimeNs: mtime.UnixNano(), SHA256: "00"}, 13) put(src+"/a/waiting.bin", "back again") add("a/waiting.bin", LedgerEntry{Size: 10, MTimeNs: mtime.UnixNano(), SHA256: "00"}, 14) e = put(dst+"/a/unhashed", "x") e.SHA256, e.Note = "", "not a regular file" add("a/unhashed", e, 15) add("../escape", LedgerEntry{Size: 1, SHA256: "00"}, 16) // moved twice: only the newer entry counts, and it matches add("a/twice.bin", LedgerEntry{Size: 99, SHA256: "stale"}, 5) add("a/twice.bin", put(dst+"/a/twice.bin", "second time"), 17) lp := filepath.Join(root, "ledger.jsonl") f, _ := os.Create(lp) for _, e := range ledger { b, _ := json.Marshal(e) f.Write(append(b, '\n')) } f.WriteString("not json\n") f.Close() tg := LandingTarget{Src: src, Dst: dst, Ledger: lp} r := Verify(tg, "manual", "all", 0, nil) want := map[string]int{"checked": 8, "matched": 2, "mismatched": 1, "changed": 1, "gone": 1, "waiting": 1, "unhashed": 1, "unreadable": 1} got := map[string]int{"checked": r.Checked, "matched": r.Matched, "mismatched": r.Mismatched, "changed": r.Changed, "gone": r.Gone, "waiting": r.Waiting, "unhashed": r.Unhashed, "unreadable": r.Unreadable} for k, v := range want { if got[k] != v { t.Errorf("%s: got %d want %d (%+v)", k, got[k], v, r) } } if r.OK() || r.Cursor != 17 || r.Error != "" { t.Errorf("ok=%v cursor=%d err=%q", r.OK(), r.Cursor, r.Error) } kinds := map[string]string{} for _, i := range r.Issues { kinds[i.Path] = i.Kind } if kinds["a/mismatch.bin"] != "mismatch" || kinds["a/gone.bin"] != "gone" || kinds["../escape"] != "unreadable" { t.Errorf("issues %+v", r.Issues) } // "new" after a pass at cursor 14 only looks at what moved later r = Verify(tg, "manual", "new", 14, nil) if r.Checked != 3 || r.Matched != 1 || r.Unhashed != 1 || r.Unreadable != 1 { t.Errorf("new since 14: %+v", r) } // records: the cursor only advances on a pass that read the ledger dir := t.TempDir() if err := RecordVerify(dir, tg, Verify(tg, "manual", "all", 0, nil)); err != nil { t.Fatal(err) } if c := VerifyCursor(dir, tg); c != 17 { t.Errorf("cursor %d", c) } bad := Verify(LandingTarget{Src: src, Dst: dst, Ledger: filepath.Join(root, "missing")}, "manual", "new", 0, nil) if !strings.Contains(bad.Error, "no ledger yet") { t.Errorf("missing ledger: %q", bad.Error) } if len(VerifyHistory(dir)) != 1 || LastVerify(dir, "manual") == nil { t.Errorf("history/last not kept") } } func TestVerifyPolicy(t *testing.T) { p := VerifyPolicy{ID: "abc", Name: "nightly", Every: "daily", At: "05:10", Scope: "new", Enabled: true} if err := p.Validate(); err != nil { t.Fatal(err) } if x, _ := p.CronExpr(); x != "10 5 * * *" { t.Fatalf("cron %q", x) } txt, err := VerifyCronText([]VerifyPolicy{p, {ID: "off", Name: "paused one", Every: "hourly", Scope: "all"}}, "core", "/usr/local/bin/hamlab", "/cfg.json") if err != nil || !strings.Contains(txt, "10 5 * * * core /usr/local/bin/hamlab -config /cfg.json landing-verify abc") || !strings.Contains(txt, "# paused one — paused") { t.Fatalf("%v\n%s", err, txt) } for _, b := range []VerifyPolicy{{Every: "daily", Scope: "some"}, {Every: "sometimes", Scope: "new"}, {Every: "custom", Scope: "new"}} { if b.Validate() == nil { t.Errorf("accepted %+v", b) } } } func TestStaleFiles(t *testing.T) { d := t.TempDir() now := time.Now() os.MkdirAll(d+"/x", 0o755) for name, age := range map[string]time.Duration{"x/old.bin": 3 * time.Hour, "x/older.bin": 5 * time.Hour, "fresh.bin": time.Minute} { os.WriteFile(filepath.Join(d, name), []byte("12345"), 0o644) os.Chtimes(filepath.Join(d, name), now.Add(-age), now.Add(-age)) } files, n, b := StaleFiles(d, 1, now) if n != 2 || b != 10 || len(files) != 1 || files[0].Path != "x/older.bin" { t.Fatalf("%d %d %+v", n, b, files) } }