153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/metazla/meta-core/internal/kv"
|
|
)
|
|
|
|
// newTestClient opens a fresh database in the test's temp dir.
|
|
func newTestClient(t *testing.T) *Client {
|
|
t.Helper()
|
|
c := NewClient()
|
|
if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = c.Close() })
|
|
return c
|
|
}
|
|
|
|
// allKeys lists every row (test helper; full scan).
|
|
func allKeys(t *testing.T, c *Client) []string {
|
|
t.Helper()
|
|
var keys []string
|
|
if err := c.Store().ScanPrefix(context.Background(), "", 1000, func(rows []kv.KV) error {
|
|
for _, r := range rows {
|
|
keys = append(keys, r.K)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the
|
|
// leak: ClearAllMetadata used to delete every record key and the root index,
|
|
// but left the cid:<cid> → root reverse-index entries behind. Those stale
|
|
// aliases are not cosmetic — ResolveRoot consults them, so a surviving entry
|
|
// silently redirects a later write into a deleted root.
|
|
func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
|
|
c := newTestClient(t)
|
|
|
|
// Two records, each with several CID aliases (a midhash plus siblings) —
|
|
// exactly what the watcher produces.
|
|
aliases := map[string][]string{}
|
|
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
|
|
uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
|
|
if err != nil {
|
|
t.Fatalf("mint: %v", err)
|
|
}
|
|
cids := []string{"bafkmid" + uuid, "bafkfull" + uuid}
|
|
for _, cidStr := range cids {
|
|
if err := c.AddAlias(uuid, cidStr); err != nil {
|
|
t.Fatalf("add alias %s: %v", cidStr, err)
|
|
}
|
|
}
|
|
if _, err := c.AddDuplicatePath(uuid, "watch/copy-"+f); err != nil {
|
|
t.Fatalf("add duplicate: %v", err)
|
|
}
|
|
aliases[uuid] = cids
|
|
}
|
|
|
|
// An entry already orphaned by a previous (buggy) clear: a reverse-index
|
|
// key whose root no longer exists. The sweep is expected to collect it too.
|
|
if err := c.SetRawValue(CIDIndexPrefix+"bafkorphan", "01OLDDEADROOT"); err != nil {
|
|
t.Fatalf("seed orphan: %v", err)
|
|
}
|
|
|
|
for uuid, cids := range aliases {
|
|
for _, cidStr := range cids {
|
|
if got := c.ResolveRoot(cidStr); got != uuid {
|
|
t.Fatalf("pre-clear ResolveRoot(%s) = %q, want %q", cidStr, got, uuid)
|
|
}
|
|
}
|
|
}
|
|
|
|
deleted, err := c.ClearAllMetadata()
|
|
if err != nil {
|
|
t.Fatalf("ClearAllMetadata: %v", err)
|
|
}
|
|
if deleted != 2 {
|
|
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
|
|
}
|
|
|
|
// Nothing record-shaped may survive: not the records, not their aliases,
|
|
// duplicates or search rows, not the pre-existing orphan. (The event
|
|
// streams and the schema sentinel are not metadata and stay.)
|
|
for _, k := range allKeys(t, c) {
|
|
for _, ns := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS, dupNS} {
|
|
if strings.HasPrefix(k, ns) {
|
|
t.Errorf("key survived the clear: %q", k)
|
|
}
|
|
}
|
|
}
|
|
var ftsRows int
|
|
if err := c.Store().DB().QueryRow(`SELECT count(*) FROM fts`).Scan(&ftsRows); err != nil || ftsRows != 0 {
|
|
t.Errorf("fts rows after clear = %d (err %v), want 0", ftsRows, err)
|
|
}
|
|
|
|
for _, cids := range aliases {
|
|
for _, cidStr := range cids {
|
|
if got := c.ResolveRoot(cidStr); got != cidStr {
|
|
t.Errorf("post-clear ResolveRoot(%s) = %q, want the hash unchanged (stale alias)", cidStr, got)
|
|
}
|
|
}
|
|
}
|
|
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
|
|
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
|
|
}
|
|
}
|
|
|
|
// TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius:
|
|
// keys outside the metadata namespaces (magnet cache, coverage markers, a key
|
|
// that merely CONTAINS "cid:") must survive a metadata wipe.
|
|
func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
|
|
c := newTestClient(t)
|
|
|
|
uuid, err := c.Mint("watch/a.mkv", 100, 1000)
|
|
if err != nil {
|
|
t.Fatalf("mint: %v", err)
|
|
}
|
|
if err := c.AddAlias(uuid, "bafkmine"); err != nil {
|
|
t.Fatalf("add alias: %v", err)
|
|
}
|
|
for k, v := range map[string]string{"other:cid:bafktheirs": "01THEIRROOT", "btmagnet:abc": "magnet:?xt=urn:btih:abc", "unrelated": "keep-me"} {
|
|
if err := c.SetRawValue(k, v); err != nil {
|
|
t.Fatalf("seed %s: %v", k, err)
|
|
}
|
|
}
|
|
|
|
if _, err := c.ClearAllMetadata(); err != nil {
|
|
t.Fatalf("ClearAllMetadata: %v", err)
|
|
}
|
|
|
|
for _, k := range []string{"other:cid:bafktheirs", "btmagnet:abc", "unrelated"} {
|
|
if _, _, ok, _ := c.GetKeyInfo(k); !ok {
|
|
t.Errorf("clear deleted foreign key %q", k)
|
|
}
|
|
}
|
|
if _, _, ok, _ := c.GetKeyInfo(CIDIndexPrefix + "bafkmine"); ok {
|
|
t.Errorf("own reverse-index entry survived the clear")
|
|
}
|
|
}
|
|
|
|
// withClient runs fn against a fresh client.
|
|
func withClient(t *testing.T, fn func(t *testing.T, c *Client)) {
|
|
t.Helper()
|
|
fn(t, newTestClient(t))
|
|
}
|