You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
177 lines
6.3 KiB
177 lines
6.3 KiB
package logstorage
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func openTestStore(t *testing.T, options Options) *Store {
|
|
t.Helper()
|
|
if options.Root == "" {
|
|
options.Root = t.TempDir()
|
|
}
|
|
if options.SegmentBytes == 0 {
|
|
options.SegmentBytes = minimumSegmentSize
|
|
}
|
|
if options.FreeBytes == nil {
|
|
options.FreeBytes = func(string) (int64, error) { return 1 << 40, nil }
|
|
}
|
|
store, err := Open(options)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func TestStorePersistsChecksummedRecordsAndRebuildsIndex(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 7, 29, 1, 2, 3, 0, time.UTC)
|
|
store := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
for _, message := range []string{"first", "second"} {
|
|
if _, err := store.Append(ClassSystem, now, map[string]string{"message": message}); err != nil {
|
|
t.Fatalf("Append: %v", err)
|
|
}
|
|
}
|
|
|
|
indexPath := filepath.Join(root, "index", "log-index-v1.json")
|
|
if err := os.WriteFile(indexPath, []byte("corrupt derived index"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reopened := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
payloads, err := reopened.Read(ClassSystem)
|
|
if err != nil || len(payloads) != 2 {
|
|
t.Fatalf("Read = %d, %v", len(payloads), err)
|
|
}
|
|
var event map[string]string
|
|
if err := json.Unmarshal(payloads[1], &event); err != nil || event["message"] != "second" {
|
|
t.Fatalf("second payload = %s, %v", payloads[1], err)
|
|
}
|
|
index, err := os.ReadFile(indexPath)
|
|
if err != nil || !strings.Contains(string(index), `"next_sequence"`) {
|
|
t.Fatalf("rebuilt index = %q, %v", index, err)
|
|
}
|
|
}
|
|
|
|
func TestStoreRecoversOnlyTruncatedActiveTail(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 7, 29, 1, 2, 3, 0, time.UTC)
|
|
store := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
if _, err := store.Append(ClassAudit, now, map[string]string{"message": "durable"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
active := filepath.Join(root, "audit", "active-audit.jsonl")
|
|
file, err := os.OpenFile(active, os.O_APPEND|os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := file.WriteString(`{"format_version":1,"sequence":2`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = file.Close()
|
|
|
|
reopened := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
payloads, err := reopened.Read(ClassAudit)
|
|
if err != nil || len(payloads) != 1 {
|
|
t.Fatalf("Read after recovery = %d, %v", len(payloads), err)
|
|
}
|
|
data, _ := os.ReadFile(active)
|
|
if bytes := strings.Count(string(data), "\n"); bytes != 1 {
|
|
t.Fatalf("active newline count = %d, want 1", bytes)
|
|
}
|
|
}
|
|
|
|
func TestStoreRejectsCorruptClosedSegment(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 7, 29, 1, 2, 3, 0, time.UTC)
|
|
store := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
payload := map[string]string{"message": strings.Repeat("x", 60<<10)}
|
|
for index := 0; index < 20; index++ {
|
|
if _, err := store.Append(ClassPort, now.Add(time.Duration(index)*time.Second), payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
segments, err := filepath.Glob(filepath.Join(root, "port", "segment-port-*.jsonl"))
|
|
if err != nil || len(segments) == 0 {
|
|
t.Fatalf("closed segments = %v, %v", segments, err)
|
|
}
|
|
data, err := os.ReadFile(segments[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data[len(data)/2] ^= 0xff
|
|
if err := os.WriteFile(segments[0], data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Open(Options{Root: root, SegmentBytes: minimumSegmentSize, FreeBytes: func(string) (int64, error) { return 1 << 40, nil }}); !errors.Is(err, ErrCorruptSegment) {
|
|
t.Fatalf("Open corrupt closed segment error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStorePagesAcrossIndexedSegments(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 7, 29, 1, 2, 3, 0, time.UTC)
|
|
store := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
payload := map[string]string{"message": strings.Repeat("x", 60<<10)}
|
|
for index := 0; index < 20; index++ {
|
|
if _, err := store.Append(ClassPort, now.Add(time.Duration(index)*time.Second), payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
first, err := store.Page(ClassPort, 0, 7)
|
|
if err != nil || len(first) != 7 || first[0].Sequence != 1 || first[6].Sequence != 7 {
|
|
t.Fatalf("first page = (%d, %v), sequences %v..%v", len(first), err, first[0].Sequence, first[6].Sequence)
|
|
}
|
|
second, err := store.Page(ClassPort, first[len(first)-1].Sequence, 7)
|
|
if err != nil || len(second) != 7 || second[0].Sequence != 8 || second[6].Sequence != 14 {
|
|
t.Fatalf("second page = (%d, %v), sequences %v..%v", len(second), err, second[0].Sequence, second[6].Sequence)
|
|
}
|
|
last, err := store.Page(ClassPort, second[len(second)-1].Sequence, 7)
|
|
if err != nil || len(last) != 6 || last[0].Sequence != 15 || last[5].Sequence != 20 {
|
|
t.Fatalf("last page = (%d, %v), sequences %v..%v", len(last), err, last[0].Sequence, last[5].Sequence)
|
|
}
|
|
}
|
|
|
|
func TestStoreAppliesAgeRetentionAndHardMaximum(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
|
store := openTestStore(t, Options{Root: root, Clock: func() time.Time { return now }})
|
|
if _, err := store.Append(ClassLink, now.Add(-48*time.Hour), map[string]string{"state": "down"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.ApplyRetention(Retention{AuditDays: 365, SystemDays: 365, PortDays: 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payloads, err := store.Read(ClassLink)
|
|
if err != nil || len(payloads) != 0 {
|
|
t.Fatalf("expired payloads = %d, %v", len(payloads), err)
|
|
}
|
|
if err := store.ApplyRetention(Retention{AuditDays: 366, SystemDays: 1, PortDays: 1}); err == nil {
|
|
t.Fatal("ApplyRetention accepted more than 365 days")
|
|
}
|
|
}
|
|
|
|
func TestStoreProtectsAuditReservationAtClassCap(t *testing.T) {
|
|
store := openTestStore(t, Options{AuditCapBytes: 128})
|
|
_, err := store.Append(ClassAudit, time.Now(), map[string]string{"message": strings.Repeat("x", 256)})
|
|
if !errors.Is(err, ErrCapacityExhausted) {
|
|
t.Fatalf("Append error = %v, want capacity exhausted", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreRefusesSymlinkedClassDirectory(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := t.TempDir()
|
|
if err := os.Symlink(target, filepath.Join(root, "audit")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
_, err := Open(Options{Root: root, SegmentBytes: minimumSegmentSize})
|
|
if err == nil {
|
|
t.Fatal("Open accepted symlinked audit directory")
|
|
}
|
|
}
|
|
|