package archiver import ( "bytes" "context" "errors" "io" "testing" "time" "git.unkin.net/unkin/logarchiver/internal/batcher" "git.unkin.net/unkin/logarchiver/internal/crypto" "git.unkin.net/unkin/logarchiver/internal/index" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/armor" ) // --- fakes --- type fakeStore struct { bucket string objects map[string][]byte failPut bool } func newFakeStore() *fakeStore { return &fakeStore{bucket: "test-bucket", objects: map[string][]byte{}} } func (f *fakeStore) Put(_ context.Context, key string, body io.Reader, _ int64) error { if f.failPut { return errors.New("simulated s3 failure") } data, err := io.ReadAll(body) if err != nil { return err } f.objects[key] = data return nil } func (f *fakeStore) Get(_ context.Context, key string) (io.ReadCloser, error) { data, ok := f.objects[key] if !ok { return nil, errors.New("not found") } return io.NopCloser(bytes.NewReader(data)), nil } func (f *fakeStore) List(_ context.Context, _ string) ([]string, error) { return nil, nil } func (f *fakeStore) Bucket() string { return f.bucket } type fakeIndex struct { rows []index.Row fail bool } func (f *fakeIndex) Insert(_ context.Context, row index.Row) error { if f.fail { return errors.New("simulated index failure") } f.rows = append(f.rows, row) return nil } func (f *fakeIndex) Search(context.Context, index.SearchQuery) ([]index.Result, error) { return nil, nil } func (f *fakeIndex) InitSchema(context.Context) error { return nil } func (f *fakeIndex) Ping(context.Context) error { return nil } func (f *fakeIndex) Close() error { return nil } func testPubkey(t *testing.T) *crypto.PublicKey { t.Helper() ent, err := openpgp.NewEntity("t", "", "t@unkin.net", nil) if err != nil { t.Fatalf("NewEntity: %v", err) } var buf bytes.Buffer w, _ := armor.Encode(&buf, openpgp.PublicKeyType, nil) _ = ent.Serialize(w) _ = w.Close() pk, err := crypto.LoadPublicKey(buf.Bytes()) if err != nil { t.Fatalf("LoadPublicKey: %v", err) } return pk } func newTestArchiver(t *testing.T, store *fakeStore, idx index.Index) *Archiver { t.Helper() pk := testPubkey(t) kb, _ := NewKeyBuilder("archive/{{.Subject}}/{{.Year}}/{{.Month}}/{{.Day}}/") prov, err := NewPubkeyProvider(context.Background(), func(context.Context) (*crypto.PublicKey, error) { return pk, nil }) if err != nil { t.Fatalf("provider: %v", err) } a, err := New(Options{ Keys: kb, Pubkeys: prov, Store: store, Index: idx, KeyName: "logarchive", FrameSize: 4096, }) if err != nil { t.Fatalf("New: %v", err) } return a } func sampleBatch() *batcher.Batch { ts := time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC) return &batcher.Batch{ Subject: "logs.k8s.vault.audit", Items: []batcher.Item{ {Subject: "logs.k8s.vault.audit", Host: "node-1", Timestamp: ts, HasTS: true, Raw: []byte(`{"host":"node-1","message":"a"}`)}, {Subject: "logs.k8s.vault.audit", Host: "node-2", Timestamp: ts.Add(time.Hour), HasTS: true, Raw: []byte(`{"host":"node-2","message":"b"}`)}, }, RawBytes: 62, } } func TestStoreSuccessWritesObjectAndIndex(t *testing.T) { store := newFakeStore() idx := &fakeIndex{} a := newTestArchiver(t, store, idx) res, err := a.Store(context.Background(), sampleBatch()) if err != nil { t.Fatalf("Store: %v", err) } if res.Events != 2 { t.Errorf("events = %d", res.Events) } if len(store.objects) != 1 { t.Fatalf("expected 1 stored object, got %d", len(store.objects)) } // Stored bytes must be a valid LARC1 container. obj := store.objects[res.ObjectKey] if _, _, err := crypto.ReadHeader(bytes.NewReader(obj)); err != nil { t.Errorf("stored object is not a valid container: %v", err) } if len(idx.rows) != 1 { t.Fatalf("expected 1 index row, got %d", len(idx.rows)) } row := idx.rows[0] if row.Subject != "logs.k8s.vault.audit" { t.Errorf("row subject = %q", row.Subject) } if row.EventCount != 2 { t.Errorf("row event_count = %d", row.EventCount) } if len(row.Hosts) != 2 || row.Hosts[0] != "node-1" || row.Hosts[1] != "node-2" { t.Errorf("row hosts = %v", row.Hosts) } if row.Bucket != "test-bucket" { t.Errorf("row bucket = %q", row.Bucket) } if row.ContainerFormat != "LARC1" || row.Compression != "zstd" { t.Errorf("row metadata wrong: %+v", row) } if row.KeyFingerprint == "" { t.Errorf("row missing key fingerprint") } } // The central at-least-once property: if S3 fails, Store errors and NOTHING is // written to the index, so the caller will not ack. func TestStoreS3FailureNoIndexNoAck(t *testing.T) { store := newFakeStore() store.failPut = true idx := &fakeIndex{} a := newTestArchiver(t, store, idx) if _, err := a.Store(context.Background(), sampleBatch()); err == nil { t.Fatalf("expected error when S3 put fails") } if len(idx.rows) != 0 { t.Errorf("index written despite S3 failure: %d rows", len(idx.rows)) } if len(store.objects) != 0 { t.Errorf("object recorded despite S3 failure") } } // If indexing fails after the S3 put, Store still errors (so no ack); the object // exists but is unindexed — acceptable, it will be re-stored on redelivery. func TestStoreIndexFailureErrors(t *testing.T) { store := newFakeStore() idx := &fakeIndex{fail: true} a := newTestArchiver(t, store, idx) if _, err := a.Store(context.Background(), sampleBatch()); err == nil { t.Fatalf("expected error when index insert fails") } if len(store.objects) != 1 { t.Errorf("object should still be in S3 (orphan), got %d", len(store.objects)) } } func TestStoreNilIndexOK(t *testing.T) { store := newFakeStore() a := newTestArchiver(t, store, nil) if _, err := a.Store(context.Background(), sampleBatch()); err != nil { t.Fatalf("Store with nil index: %v", err) } if len(store.objects) != 1 { t.Errorf("expected object stored") } } func TestStoreEmptyBatchNoop(t *testing.T) { store := newFakeStore() a := newTestArchiver(t, store, &fakeIndex{}) res, err := a.Store(context.Background(), &batcher.Batch{Subject: "s"}) if err != nil { t.Fatalf("empty batch: %v", err) } if res.ObjectKey != "" || len(store.objects) != 0 { t.Errorf("empty batch should store nothing") } }