package catalog import ( "encoding/binary" "errors" "fmt" "math" "math/rand" "path/filepath" "testing" "time" "git.k6n.net/mats/platform/money" ) // TestSnapshotRoundTrip — write 1000 projections in shuffled order, read back // and verify all 1000 are found at the right SKU. func TestSnapshotRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "snap.bin") const n = 1000 w := NewSnapshotWriter[Projection]().WithTenant("default") // intentionally shuffled input — sort is what reorders at Write. perm := rand.Perm(n) for _, i := range perm { sku := fmt.Sprintf("SKU-%05d", i) if err := appendP(w, makeProjection(sku, int64(100+(i%97)*100))); err != nil { t.Fatalf("Append %s: %v", sku, err) } } if w.Len() != n { t.Fatalf("Len: got %d want %d", w.Len(), n) } final, err := w.Write(dir, "snap.bin") if err != nil { t.Fatalf("Write: %v", err) } if final != path { t.Errorf("Write returned %q expected %q", final, path) } r, err := ReadSnapshotFile[Projection](path) if err != nil { t.Fatalf("ReadSnapshotFile: %v", err) } defer r.Close() if r.Len() != n { t.Errorf("Len: got %d want %d", r.Len(), n) } if r.Tenant() != "default" { t.Errorf("Tenant: got %q want %q", r.Tenant(), "default") } // every SKU present and correct for _, i := range rand.Perm(n)[:50] { // probe 50 random lookups sku := fmt.Sprintf("SKU-%05d", i) got, ok := r.Get(sku) if !ok { t.Errorf("Get(%q) not found", sku) continue } want := money.Cents(100 + (i%97)*100) if got.PriceIncVat != want { t.Errorf("Get(%q) PriceIncVat: got %d want %d", sku, got.PriceIncVat, want) } } // missing SKU if _, ok := r.Get("NOPE-99999"); ok { t.Error("Get on missing SKU should return ok=false") } } // TestSnapshotCompareSKUInvariant — verify the on-disk record section is // sorted under the same compareSKU total order that BinarySearch uses. // If sort.Slice and Reader.Get ever drift apart, lookups silently miss // — this test pins that invariant. func TestSnapshotCompareSKUInvariant(t *testing.T) { dir := t.TempDir() w := NewSnapshotWriter[Projection]() // deliberately inverted order after Append (so sort has work to do) perm := rand.Perm(500) for _, i := range perm { sku := fmt.Sprintf("XX-%05d", i) if err := appendP(w, makeProjection(sku, int64(i))); err != nil { t.Fatal(err) } } if _, err := w.Write(dir, "snap.bin"); err != nil { t.Fatal(err) } r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin")) if err != nil { t.Fatal(err) } defer r.Close() // Walk every record slot, harvest SKU bytes from the pad-then-trim // form, and assert non-decreasing order under compareSKU. prevSKU := "" for i := 0; i < r.Len(); i++ { recOff := int(r.raw.recordStart) + i*recordSize raw := r.raw.data[recOff : recOff+32] // trim trailing zeros end := len(raw) for end > 0 && raw[end-1] == 0 { end-- } cur := string(raw[:end]) if i > 0 { if compareSKU(runesToPadded32(prevSKU), []byte(cur)) > 0 { t.Errorf("record section out of order at i=%d: prev=%q cur=%q", i, prevSKU, cur) } } prevSKU = cur } } // runesToPadded32 zero-pads s into a 32-byte slot for compareSKU in the // invariant test. func runesToPadded32(s string) []byte { out := make([]byte, 32) copy(out, s) return out } // TestSnapshotTenantRoundTrip — Writer.WithTenant lands in the header and // Reader surfaces it. Without this, one file per tenant is just a convention // with no on-disk evidence. func TestSnapshotTenantRoundTrip(t *testing.T) { dir := t.TempDir() w := NewSnapshotWriter[Projection]().WithTenant("bygglagret") if err := appendP(w, makeProjection("ABC-1", 100)); err != nil { t.Fatal(err) } if _, err := w.Write(dir, "snap.bin"); err != nil { t.Fatal(err) } r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin")) if err != nil { t.Fatal(err) } defer r.Close() if r.Tenant() != "bygglagret" { t.Errorf("Tenant: got %q want %q", r.Tenant(), "bygglagret") } // Empty tenant writes zero-padded. w2 := NewSnapshotWriter[Projection]() if err := appendP(w2, makeProjection("ABC-2", 200)); err != nil { t.Fatal(err) } if _, err := w2.Write(dir, "snap2.bin"); err != nil { t.Fatal(err) } r2, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap2.bin")) if err != nil { t.Fatal(err) } defer r2.Close() if r2.Tenant() != "" { t.Errorf("Tenant (no tag): got %q want empty", r2.Tenant()) } } // TestSnapshotTenantTooLong — over-long tenant name rejected at Write. func TestSnapshotTenantTooLong(t *testing.T) { dir := t.TempDir() w := NewSnapshotWriter[Projection]().WithTenant(string(make([]byte, 33))) if err := appendP(w, makeProjection("ABC", 1)); err != nil { t.Fatal(err) } if _, err := w.Write(dir, "snap.bin"); err == nil { t.Error("expected error for over-long tenant") } else if !errors.Is(err, ErrTenantTooLong) { t.Errorf("got %v want ErrTenantTooLong", err) } } // TestSnapshotCountOverflowRejected — tampered count that would cause // recordStart + records*recordSize to overflow uint64 must be rejected // with ErrSnapshotOverflow (formerly a silent wraparound vulnerability). func TestSnapshotCountOverflowRejected(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "over.bin") bs := make([]byte, headerSize) binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion) binary.LittleEndian.PutUint32(bs[12:16], 0) // count that overflows when multiplied by recordSize=44 binary.LittleEndian.PutUint64(bs[16:24], math.MaxUint64/uint64(recordSize)+1) binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) if err := os2WriteFile(path, bs, 0o644); err != nil { t.Fatal(err) } if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotOverflow) { t.Errorf("overflow count rejected with %v, want ErrSnapshotOverflow", err) } } // TestSnapshotAtomicSwap — verify the temporary-then-rename discipline never // leaves a half-written snap.bin visible to readers. func TestSnapshotAtomicSwap(t *testing.T) { dir := t.TempDir() w := NewSnapshotWriter[Projection]() mustAppend := func(w *SnapshotWriter[Projection], sku string, price int64) { t.Helper() if err := appendP(w, makeProjection(sku, price)); err != nil { t.Fatalf("Append %s: %v", sku, err) } } mustAppend(w, "AAA", 100) final, err := w.Write(dir, "snap.bin") if err != nil { t.Fatal(err) } // tmp must not exist anymore if _, err := os2Stat(final + ".tmp"); err == nil { t.Errorf(".tmp still present after Write") } // replace w2 := NewSnapshotWriter[Projection]() mustAppend(w2, "ZZZ", 200) if _, err := w2.Write(dir, "snap.bin"); err != nil { t.Fatal(err) } r, err := ReadSnapshotFile[Projection](final) if err != nil { t.Fatal(err) } defer r.Close() if _, ok := r.Get("ZZZ"); !ok { t.Error("second-snapshot Get(ZZZ) missing") } if _, ok := r.Get("AAA"); ok { t.Error("first-snapshot residue Get(AAA) found — atomic swap bug") } } // TestSnapshotRejectBadInput — magic, version, truncation, blob-offset OOB. func TestSnapshotRejectBadInput(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "bad.bin") // Empty file: truncated. if err := os2WriteFile(path, []byte{}, 0o644); err != nil { t.Fatal(err) } if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotTruncated) { t.Errorf("empty file rejected with %v, want ErrSnapshotTruncated", err) } // Wrong magic. The fixture must be at least headerSize bytes long // (v2 header is 72 B), otherwise we'd hit ErrSnapshotTruncated // before the magic check fires. wrongMagic := make([]byte, headerSize+1) for i := range wrongMagic { wrongMagic[i] = 0xff } if err := os2WriteFile(path, wrongMagic, 0o644); err != nil { t.Fatal(err) } if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotMagic) { t.Errorf("wrong magic rejected with %v, want ErrSnapshotMagic", err) } // Truncated mid-record. A valid header claiming 5 records but with no // record section. if err := os2WriteFile(path, buildValidHeader(5), 0o644); err != nil { t.Fatal(err) } if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotTruncated) { t.Errorf("truncated snapshot rejected with %v, want ErrSnapshotTruncated", err) } // Tampered blob offsets: valid header, valid record section with a // blob pointer that lies past EOF. Should reject with ErrSnapshotBadBlob. tampered := buildTamperedHeader() if err := os2WriteFile(path, tampered, 0o644); err != nil { t.Fatal(err) } if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotBadBlob) { t.Errorf("tampered-blob rejected with %v, want ErrSnapshotBadBlob", err) } } // TestSnapshotEmpty — empty (count=0) snapshot is valid; Get returns false. func TestSnapshotEmpty(t *testing.T) { dir := t.TempDir() w := NewSnapshotWriter[Projection]() if _, err := w.Write(dir, "empty.bin"); err != nil { t.Fatal(err) } r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "empty.bin")) if err != nil { t.Fatal(err) } defer r.Close() if r.Len() != 0 { t.Errorf("Len: got %d want 0", r.Len()) } if _, ok := r.Get("anything"); ok { t.Error("empty snapshot should never return ok=true") } } // TestSnapshotSKU32 — SKU exactly 32 chars (the boundary case) must succeed. func TestSnapshotSKU32(t *testing.T) { dir := t.TempDir() sku := "SKU-" + repeat32("X", 32-4) // 4 + 28 = 32 chars if len(sku) != 32 { t.Fatalf("test bug: sku length %d", len(sku)) } w := NewSnapshotWriter[Projection]() if err := appendP(w, makeProjection(sku, 1)); err != nil { t.Fatalf("Append 32-char SKU: %v", err) } if _, err := w.Write(dir, "snap.bin"); err != nil { t.Fatal(err) } r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin")) if err != nil { t.Fatal(err) } defer r.Close() if _, ok := r.Get(sku); !ok { t.Error("32-char SKU Get returned false") } } // TestSnapshotSKU33Rejected — one char over should error at Append. func TestSnapshotSKU33Rejected(t *testing.T) { w := NewSnapshotWriter[Projection]() sku := repeat32("X", 33) err := appendP(w, makeProjection(sku, 1)) if !errors.Is(err, ErrSKUTooLong) { t.Errorf("33-char SKU Append: got %v want ErrSKUTooLong", err) } } // TestSnapshotGetSpeed — probe thousands of random SKU lookups to give a // sense of the binary-search cost (not a benchmark per se). func TestSnapshotGetSpeed(t *testing.T) { if testing.Short() { t.Skip("short mode") } dir := t.TempDir() const n = 5000 w := NewSnapshotWriter[Projection]() skus := make([]string, n) for i := 0; i < n; i++ { skus[i] = fmt.Sprintf("Z%06d", i) if err := appendP(w, makeProjection(skus[i], int64(i))); err != nil { t.Fatalf("Append %s: %v", skus[i], err) } } if _, err := w.Write(dir, "snap.bin"); err != nil { t.Fatal(err) } r, err := ReadSnapshotFile[Projection](filepath.Join(dir, "snap.bin")) if err != nil { t.Fatal(err) } defer r.Close() start := time.Now() hits, misses := 0, 0 for _, i := range rand.Perm(n) { if _, ok := r.Get(skus[i]); ok { hits++ } else { misses++ } } elapsed := time.Since(start) t.Logf("100%% hit Get on %d records: %d hits, %d misses, %v (%v/lookup)", n, hits, misses, elapsed, elapsed/time.Duration(n)) if misses != 0 { t.Errorf("expected zero misses on full-key set, got %d", misses) } } // buildValidHeader synthesises a header byte slice with the given Count // and zero-blob / zero-record section, used to exercise the truncation // branch in newSnapshotReader. func buildValidHeader(count uint64) []byte { bs := make([]byte, headerSize) binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion) binary.LittleEndian.PutUint32(bs[12:16], 0) binary.LittleEndian.PutUint64(bs[16:24], count) binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) // blobStart binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) // recordStart (truncated) return bs } // buildTamperedHeader returns a buffer with a valid header claiming 1 record // AND its 44-byte record section, but the record's blob pointer lies past // EOF. Used to exercise ErrSnapshotBadBlob. func buildTamperedHeader() []byte { bs := make([]byte, headerSize+recordSize) binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion) binary.LittleEndian.PutUint32(bs[12:16], 0) binary.LittleEndian.PutUint64(bs[16:24], 1) binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) binary.LittleEndian.PutUint64(bs[headerSize+32:headerSize+40], ^uint64(0)) binary.LittleEndian.PutUint32(bs[headerSize+40:headerSize+44], ^uint32(0)) return bs } // TestSnapshotRejectBadVersion — pin the ErrSnapshotVersion rejection path. func TestSnapshotRejectBadVersion(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "badver.bin") bs := make([]byte, headerSize) binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion+1) binary.LittleEndian.PutUint32(bs[12:16], 0) binary.LittleEndian.PutUint64(bs[16:24], 0) binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) if err := os2WriteFile(path, bs, 0o644); err != nil { t.Fatal(err) } if _, err := ReadSnapshotFile[Projection](path); !errors.Is(err, ErrSnapshotVersion) { t.Errorf("v2-not-supported header rejected with %v, want ErrSnapshotVersion", err) } } // makeProjection helper repeated here for the new tests. func makeProjection(sku string, price int64) Projection { return Projection{ ID: "sku:" + sku, SKU: sku, Title: "Item " + sku, PriceIncVat: money.Cents(price), Currency: "SEK", TaxClass: "standard", UpdatedAt: time.Now().UnixMilli(), } }