feat: store layer with indexes, optimistic concurrency, GridFS, credential crypto (phase 2)

- mongo-driver v2 connection with bounded startup retry + /readyz gate
- idempotent creation of all §4.9 indexes
- UpdateVersioned: version-filtered updates, conflict vs not-found errors
- AES-256-GCM Seal/Open for ticketing credentials, base64(nonce|ct)
- GridFS file store: MIME sniffing, MAX_UPLOAD_MB cap, sha256 metadata
- HMAC-signed short-lived file URL tokens (§5.1, 1h TTL)
- integration tests against compose Mongo via docker-compose.test.yml overlay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 18:17:26 +02:00
parent 976f5238f8
commit f2c534f636
18 changed files with 1129 additions and 3 deletions
+145
View File
@@ -0,0 +1,145 @@
//go:build integration
package files
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"strings"
"testing"
"bountyboard/internal/testutil"
)
func TestSaveOpenRoundTrip(t *testing.T) {
db := testutil.MongoDB(t)
s := NewStore(db, 25)
ctx := context.Background()
content := []byte("%PDF-1.4 fake pdf content for sniffing")
meta, err := s.Save(ctx, ScopeTask, "01JOWNER", "spec.pdf", "", bytes.NewReader(content))
if err != nil {
t.Fatalf("Save: %v", err)
}
if meta.MimeType != "application/pdf" {
t.Errorf("sniffed mime = %q, want application/pdf", meta.MimeType)
}
if meta.Size != int64(len(content)) {
t.Errorf("size = %d, want %d", meta.Size, len(content))
}
wantSum := sha256.Sum256(content)
if meta.SHA256 != hex.EncodeToString(wantSum[:]) {
t.Errorf("sha256 mismatch")
}
rc, got, err := s.Open(ctx, meta.ID)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, content) {
t.Error("downloaded content differs")
}
if got.OwnerID != "01JOWNER" || got.Scope != ScopeTask || got.Name != "spec.pdf" {
t.Errorf("metadata round trip: %+v", got)
}
if got.SHA256 != meta.SHA256 || got.MimeType != meta.MimeType {
t.Errorf("stored metadata mismatch: %+v vs %+v", got, meta)
}
}
func TestDeclaredMimeUsedWhenSniffInconclusive(t *testing.T) {
db := testutil.MongoDB(t)
s := NewStore(db, 25)
meta, err := s.Save(context.Background(), ScopeChat, "01JOWNER", "data.bin",
"application/x-custom", bytes.NewReader([]byte{0x01, 0x02, 0x03}))
if err != nil {
t.Fatal(err)
}
if meta.MimeType != "application/x-custom" {
t.Errorf("mime = %q, want declared application/x-custom", meta.MimeType)
}
// But a confident sniff overrides a lying declaration.
meta2, err := s.Save(context.Background(), ScopeChat, "01JOWNER", "fake.txt",
"text/plain", strings.NewReader("\x89PNG\r\n\x1a\n123456789"))
if err != nil {
t.Fatal(err)
}
if meta2.MimeType != "image/png" {
t.Errorf("mime = %q, want sniffed image/png", meta2.MimeType)
}
}
func TestSizeLimitEnforced(t *testing.T) {
db := testutil.MongoDB(t)
s := NewStore(db, 1) // 1 MiB cap
big := io.LimitReader(neverEnding('x'), 1<<20+1)
_, err := s.Save(context.Background(), ScopeChat, "01JOWNER", "big.bin", "", big)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("want ErrTooLarge, got %v", err)
}
// No orphaned file documents may remain.
n, err := s.bucket.GetFilesCollection().CountDocuments(context.Background(), struct{}{})
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("oversize upload left %d file docs behind", n)
}
// Exactly at the limit is fine.
ok := io.LimitReader(neverEnding('x'), 1<<20)
if _, err := s.Save(context.Background(), ScopeChat, "01JOWNER", "exact.bin", "", ok); err != nil {
t.Fatalf("upload at exactly the limit: %v", err)
}
}
func TestOpenMissingFile(t *testing.T) {
db := testutil.MongoDB(t)
s := NewStore(db, 25)
if _, _, err := s.Open(context.Background(), "01JNOPE"); !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestDelete(t *testing.T) {
db := testutil.MongoDB(t)
s := NewStore(db, 25)
ctx := context.Background()
meta, err := s.Save(ctx, ScopeAvatar, "01JOWNER", "a.txt", "", strings.NewReader("hello"))
if err != nil {
t.Fatal(err)
}
if err := s.Delete(ctx, meta.ID); err != nil {
t.Fatal(err)
}
if _, _, err := s.Open(ctx, meta.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("want ErrNotFound after delete, got %v", err)
}
if err := s.Delete(ctx, meta.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("double delete: want ErrNotFound, got %v", err)
}
}
// neverEnding is an infinite reader of one repeated byte.
type neverEnding byte
func (b neverEnding) Read(p []byte) (int, error) {
for i := range p {
p[i] = byte(b)
}
return len(p), nil
}