Store now indexes every Save into the upload_files table via SetRepo. DB failures are logged as warnings and do not fail the upload because .meta.json remains the source of truth and startup backfill recovers. Add Store.Backfill to walk Root at startup and insert index rows for any pre-existing .meta.json sidecars, swallowing duplicate-key races. The upload_file MCP Tool now writes an audit_log entry on success. Tests cover dual-write args, repo-error non-failure, and backfill skipping existing rows. Co-Authored-By: Claude <noreply@anthropic.com>
534 lines
14 KiB
Go
534 lines
14 KiB
Go
package uploads
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"testing"
|
|
"time"
|
|
|
|
"spark-mcp-go/internal/storage"
|
|
)
|
|
|
|
func TestStore_SaveAndRetrieve(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
data := []byte("hello uploads")
|
|
sum := sha256.Sum256(data)
|
|
wantSHA := hex.EncodeToString(sum[:])
|
|
|
|
fileID, name, size, sha256Hex, absPath, err := store.Save(data, "hello.txt")
|
|
if err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
if fileID == "" || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(fileID) {
|
|
t.Errorf("fileID=%q, want 32 lowercase hex chars", fileID)
|
|
}
|
|
if name != "hello.txt" {
|
|
t.Errorf("name=%q, want hello.txt", name)
|
|
}
|
|
if size != int64(len(data)) {
|
|
t.Errorf("size=%d, want %d", size, len(data))
|
|
}
|
|
if sha256Hex != wantSHA {
|
|
t.Errorf("sha256=%q, want %q", sha256Hex, wantSHA)
|
|
}
|
|
if !filepath.IsAbs(absPath) {
|
|
t.Errorf("absPath=%q is not absolute", absPath)
|
|
}
|
|
if filepath.Base(absPath) != fileID {
|
|
t.Errorf("absPath base=%q, want fileID %q", filepath.Base(absPath), fileID)
|
|
}
|
|
|
|
gotData, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
t.Fatalf("read data file: %v", err)
|
|
}
|
|
if string(gotData) != string(data) {
|
|
t.Errorf("data=%q, want %q", gotData, data)
|
|
}
|
|
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
t.Fatalf("stat data file: %v", err)
|
|
}
|
|
if info.Mode().Perm() != 0o640 {
|
|
t.Errorf("data mode=%o, want %o", info.Mode().Perm(), 0o640)
|
|
}
|
|
|
|
metaPath := absPath + ".meta.json"
|
|
metaBytes, err := os.ReadFile(metaPath)
|
|
if err != nil {
|
|
t.Fatalf("read sidecar: %v", err)
|
|
}
|
|
var sc sidecar
|
|
if err := json.Unmarshal(metaBytes, &sc); err != nil {
|
|
t.Fatalf("unmarshal sidecar: %v", err)
|
|
}
|
|
if sc.Name != "hello.txt" {
|
|
t.Errorf("sidecar name=%q, want hello.txt", sc.Name)
|
|
}
|
|
if sc.Size != int64(len(data)) {
|
|
t.Errorf("sidecar size=%d, want %d", sc.Size, len(data))
|
|
}
|
|
if sc.Sha256 != wantSHA {
|
|
t.Errorf("sidecar sha256=%q, want %q", sc.Sha256, wantSHA)
|
|
}
|
|
if sc.UploadedAt.IsZero() {
|
|
t.Errorf("sidecar uploaded_at is zero")
|
|
}
|
|
|
|
metaInfo, err := os.Stat(metaPath)
|
|
if err != nil {
|
|
t.Fatalf("stat sidecar: %v", err)
|
|
}
|
|
if metaInfo.Mode().Perm() != 0o600 {
|
|
t.Errorf("sidecar mode=%o, want %o", metaInfo.Mode().Perm(), 0o600)
|
|
}
|
|
|
|
validatedID, err := store.Validate(absPath)
|
|
if err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
if validatedID != fileID {
|
|
t.Errorf("validatedID=%q, want %q", validatedID, fileID)
|
|
}
|
|
}
|
|
|
|
func TestStore_AbsPath_RejectsMalformed(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
validID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
if _, err := store.AbsPath(validID); err != nil {
|
|
t.Errorf("valid fileID rejected: %v", err)
|
|
}
|
|
|
|
cases := []string{
|
|
"",
|
|
"abc",
|
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
"0123456789abcdef0123456789abcdefg",
|
|
"0123456789abcdef0123456789abcde ",
|
|
}
|
|
for _, id := range cases {
|
|
t.Run(fmt.Sprintf("id=%q", id), func(t *testing.T) {
|
|
_, err := store.AbsPath(id)
|
|
if err == nil {
|
|
t.Errorf("AbsPath(%q) succeeded, want error", id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStore_Validate_RejectsOutsideRoot(t *testing.T) {
|
|
if os.PathSeparator != '/' {
|
|
t.Skip("Unix-style path test")
|
|
}
|
|
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
fileID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
if err := os.WriteFile(filepath.Join(store.Root, fileID), []byte("x"), 0o640); err != nil {
|
|
t.Fatalf("create data file: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(store.Root, fileID+".meta.json"), []byte(`{"uploaded_at":"`+time.Now().Format(time.RFC3339Nano)+`"}`), 0o600); err != nil {
|
|
t.Fatalf("create sidecar: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
}{
|
|
{"parent", filepath.Join(store.Root, "..", "other", fileID)},
|
|
{"dotdot", filepath.Join(store.Root, "..", "..", "tmp", fileID)},
|
|
{"different_volume", "/other/volume/" + fileID},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := store.Validate(c.path)
|
|
if err == nil {
|
|
t.Errorf("Validate(%q) succeeded, want error", c.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStore_Sweep_DeletesExpiredKeepsRecent(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
expiredID := "00000000000000000000000000000001"
|
|
freshID := "00000000000000000000000000000002"
|
|
noMetaID := "00000000000000000000000000000003"
|
|
|
|
create := func(id string, uploadedAt *time.Time, withData bool) {
|
|
if withData {
|
|
if err := os.WriteFile(filepath.Join(store.Root, id), []byte("x"), 0o640); err != nil {
|
|
t.Fatalf("create data file %s: %v", id, err)
|
|
}
|
|
}
|
|
if uploadedAt != nil {
|
|
sc := map[string]any{
|
|
"name": "x",
|
|
"size": 1,
|
|
"sha256": "abc",
|
|
"uploaded_at": uploadedAt.Format(time.RFC3339Nano),
|
|
}
|
|
b, _ := json.Marshal(sc)
|
|
if err := os.WriteFile(filepath.Join(store.Root, id+".meta.json"), b, 0o600); err != nil {
|
|
t.Fatalf("create sidecar %s: %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
create(expiredID, timePtr(now.Add(-2*time.Hour)), true)
|
|
create(freshID, timePtr(now), true)
|
|
create(noMetaID, nil, true)
|
|
|
|
deleted, err := store.Sweep(context.Background(), 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("sweep: %v", err)
|
|
}
|
|
if deleted != 1 {
|
|
t.Errorf("deleted=%d, want 1", deleted)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(store.Root, expiredID)); !os.IsNotExist(err) {
|
|
t.Errorf("expired data file still exists")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(store.Root, expiredID+".meta.json")); !os.IsNotExist(err) {
|
|
t.Errorf("expired sidecar still exists")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(store.Root, freshID)); err != nil {
|
|
t.Errorf("fresh data file missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(store.Root, noMetaID)); err != nil {
|
|
t.Errorf("no-sidecar data file missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_Sweep_OrphanSidecarReaped(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
id := "00000000000000000000000000000004"
|
|
sc := map[string]any{
|
|
"name": "orphan",
|
|
"size": 1,
|
|
"sha256": "abc",
|
|
"uploaded_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
|
}
|
|
b, _ := json.Marshal(sc)
|
|
if err := os.WriteFile(filepath.Join(store.Root, id+".meta.json"), b, 0o600); err != nil {
|
|
t.Fatalf("create orphan sidecar: %v", err)
|
|
}
|
|
|
|
deleted, err := store.Sweep(context.Background(), 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("sweep: %v", err)
|
|
}
|
|
if deleted != 1 {
|
|
t.Errorf("deleted=%d, want 1", deleted)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(store.Root, id+".meta.json")); !os.IsNotExist(err) {
|
|
t.Errorf("orphan sidecar still exists")
|
|
}
|
|
}
|
|
|
|
func timePtr(t time.Time) *time.Time {
|
|
return &t
|
|
}
|
|
|
|
func TestStore_Validate_RejectsSidecarPath(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
fileID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
if err := os.WriteFile(filepath.Join(store.Root, fileID), []byte("x"), 0o640); err != nil {
|
|
t.Fatalf("create data file: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(store.Root, fileID+".meta.json"), []byte(`{"uploaded_at":"`+time.Now().Format(time.RFC3339Nano)+`"}`), 0o600); err != nil {
|
|
t.Fatalf("create sidecar: %v", err)
|
|
}
|
|
|
|
_, err = store.Validate(filepath.Join(store.Root, fileID+".meta.json"))
|
|
if err == nil {
|
|
t.Errorf("Validate(sidecar) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestStore_Sweep_HonorsContextCancel(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
id := "00000000000000000000000000000006"
|
|
if err := os.WriteFile(filepath.Join(store.Root, id), []byte("x"), 0o640); err != nil {
|
|
t.Fatalf("create data file: %v", err)
|
|
}
|
|
sc := map[string]any{
|
|
"name": "x",
|
|
"size": 1,
|
|
"sha256": "abc",
|
|
"uploaded_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339Nano),
|
|
}
|
|
b, _ := json.Marshal(sc)
|
|
if err := os.WriteFile(filepath.Join(store.Root, id+".meta.json"), b, 0o600); err != nil {
|
|
t.Fatalf("create sidecar: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
deleted, err := store.Sweep(ctx, 1*time.Hour)
|
|
if err != ctx.Err() {
|
|
t.Fatalf("sweep err=%v, want %v", err, ctx.Err())
|
|
}
|
|
if deleted != 0 {
|
|
t.Errorf("deleted=%d, want 0", deleted)
|
|
}
|
|
if _, existErr := os.Stat(filepath.Join(store.Root, id)); os.IsNotExist(existErr) {
|
|
t.Logf("cancelled sweep managed to delete the file before checking ctx; this is timing-dependent and acceptable")
|
|
}
|
|
}
|
|
|
|
func TestStore_Sweep_DeletesDataWithCorruptSidecar(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
|
|
id := "00000000000000000000000000000005"
|
|
dataPath := filepath.Join(store.Root, id)
|
|
metaPath := dataPath + ".meta.json"
|
|
|
|
if err := os.WriteFile(dataPath, []byte("stale data"), 0o640); err != nil {
|
|
t.Fatalf("create data file: %v", err)
|
|
}
|
|
// Corrupt sidecar: not valid JSON.
|
|
if err := os.WriteFile(metaPath, []byte("not valid json"), 0o600); err != nil {
|
|
t.Fatalf("create sidecar: %v", err)
|
|
}
|
|
// Set mtime well in the past so the fallback triggers.
|
|
past := time.Now().Add(-2 * time.Hour)
|
|
if err := os.Chtimes(dataPath, past, past); err != nil {
|
|
t.Fatalf("set mtime: %v", err)
|
|
}
|
|
|
|
deleted, err := store.Sweep(context.Background(), 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("sweep: %v", err)
|
|
}
|
|
if deleted != 1 {
|
|
t.Errorf("deleted=%d, want 1", deleted)
|
|
}
|
|
if _, err := os.Stat(dataPath); !os.IsNotExist(err) {
|
|
t.Errorf("data file with corrupt sidecar still exists")
|
|
}
|
|
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
|
t.Errorf("corrupt sidecar still exists")
|
|
}
|
|
}
|
|
|
|
type fakeUploadRepo struct {
|
|
calls []storage.UploadMeta
|
|
err error
|
|
}
|
|
|
|
func (f *fakeUploadRepo) Create(ctx context.Context, fileID, name string, size int64, sha256Hex string, uploadedAt time.Time) error {
|
|
f.calls = append(f.calls, storage.UploadMeta{
|
|
FileID: fileID, Name: name, Size: size, SHA256: sha256Hex, UploadedAt: uploadedAt,
|
|
})
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeUploadRepo) Get(ctx context.Context, fileID string) (storage.UploadMeta, error) {
|
|
for _, m := range f.calls {
|
|
if m.FileID == fileID {
|
|
return m, nil
|
|
}
|
|
}
|
|
return storage.UploadMeta{}, storage.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeUploadRepo) List(ctx context.Context, search string, limit int) ([]storage.UploadMeta, error) {
|
|
return f.calls, nil
|
|
}
|
|
|
|
func (f *fakeUploadRepo) Delete(ctx context.Context, fileID string) error {
|
|
return nil
|
|
}
|
|
|
|
func TestStore_Save_DualWrite(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
repo := &fakeUploadRepo{}
|
|
store.SetRepo(repo)
|
|
|
|
data := []byte("dual-write test")
|
|
fileID, _, size, sha256Hex, absPath, err := store.Save(data, "dual.txt")
|
|
if err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
if absPath == "" {
|
|
t.Fatal("absPath empty")
|
|
}
|
|
if len(repo.calls) != 1 {
|
|
t.Fatalf("repo.Create calls: got %d, want 1", len(repo.calls))
|
|
}
|
|
call := repo.calls[0]
|
|
if call.FileID != fileID {
|
|
t.Errorf("FileID: got %q, want %q", call.FileID, fileID)
|
|
}
|
|
if call.Name != "dual.txt" {
|
|
t.Errorf("Name: got %q, want dual.txt", call.Name)
|
|
}
|
|
if call.Size != size {
|
|
t.Errorf("Size: got %d, want %d", call.Size, size)
|
|
}
|
|
if call.SHA256 != sha256Hex {
|
|
t.Errorf("SHA256: got %q, want %q", call.SHA256, sha256Hex)
|
|
}
|
|
if call.UploadedAt.IsZero() {
|
|
t.Errorf("UploadedAt is zero")
|
|
}
|
|
}
|
|
|
|
func TestStore_Save_RepoErrorDoesNotFailUpload(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
repo := &fakeUploadRepo{err: errors.New("db down")}
|
|
store.SetRepo(repo)
|
|
|
|
data := []byte("db error test")
|
|
fileID, _, _, _, absPath, err := store.Save(data, "error.txt")
|
|
if err != nil {
|
|
t.Fatalf("save should not fail when repo errors: %v", err)
|
|
}
|
|
if _, err := os.Stat(absPath); err != nil {
|
|
t.Errorf("data file missing: %v", err)
|
|
}
|
|
if _, err := os.Stat(absPath + ".meta.json"); err != nil {
|
|
t.Errorf("sidecar missing: %v", err)
|
|
}
|
|
if len(repo.calls) != 1 {
|
|
t.Errorf("repo.Create calls: got %d, want 1", len(repo.calls))
|
|
}
|
|
if repo.calls[0].FileID != fileID {
|
|
t.Errorf("repo call file_id: got %q, want %q", repo.calls[0].FileID, fileID)
|
|
}
|
|
}
|
|
|
|
func TestStore_Backfill(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
repo := &fakeUploadRepo{}
|
|
|
|
now := time.Now()
|
|
ids := []string{
|
|
"00000000000000000000000000000001",
|
|
"00000000000000000000000000000002",
|
|
"00000000000000000000000000000003",
|
|
}
|
|
for i, id := range ids {
|
|
sc := map[string]any{
|
|
"name": fmt.Sprintf("file%d.txt", i),
|
|
"size": i + 1,
|
|
"sha256": fmt.Sprintf("sha%d", i),
|
|
"uploaded_at": now.Add(time.Duration(i) * time.Second).Format(time.RFC3339Nano),
|
|
}
|
|
b, _ := json.Marshal(sc)
|
|
if err := os.WriteFile(filepath.Join(store.Root, id+".meta.json"), b, 0o600); err != nil {
|
|
t.Fatalf("create sidecar %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
inserted, err := store.Backfill(context.Background(), repo)
|
|
if err != nil {
|
|
t.Fatalf("backfill: %v", err)
|
|
}
|
|
if inserted != 3 {
|
|
t.Errorf("inserted=%d, want 3", inserted)
|
|
}
|
|
if len(repo.calls) != 3 {
|
|
t.Errorf("repo.Create calls: got %d, want 3", len(repo.calls))
|
|
}
|
|
|
|
// Second backfill must skip existing rows.
|
|
inserted, err = store.Backfill(context.Background(), repo)
|
|
if err != nil {
|
|
t.Fatalf("second backfill: %v", err)
|
|
}
|
|
if inserted != 0 {
|
|
t.Errorf("second inserted=%d, want 0", inserted)
|
|
}
|
|
if len(repo.calls) != 3 {
|
|
t.Errorf("repo.Create calls after second backfill: got %d, want 3", len(repo.calls))
|
|
}
|
|
}
|
|
|
|
func TestStore_Backfill_SkipsExisting(t *testing.T) {
|
|
store, err := New(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
repo := &fakeUploadRepo{}
|
|
|
|
id := "00000000000000000000000000000004"
|
|
now := time.Now()
|
|
sc := map[string]any{
|
|
"name": "existing.txt",
|
|
"size": 5,
|
|
"sha256": "sha",
|
|
"uploaded_at": now.Format(time.RFC3339Nano),
|
|
}
|
|
b, _ := json.Marshal(sc)
|
|
if err := os.WriteFile(filepath.Join(store.Root, id+".meta.json"), b, 0o600); err != nil {
|
|
t.Fatalf("create sidecar: %v", err)
|
|
}
|
|
|
|
// Pre-seed the repo so the sidecar is already indexed.
|
|
if err := repo.Create(context.Background(), id, "existing.txt", 5, "sha", now); err != nil {
|
|
t.Fatalf("seed repo: %v", err)
|
|
}
|
|
|
|
inserted, err := store.Backfill(context.Background(), repo)
|
|
if err != nil {
|
|
t.Fatalf("backfill: %v", err)
|
|
}
|
|
if inserted != 0 {
|
|
t.Errorf("inserted=%d, want 0", inserted)
|
|
}
|
|
}
|