Implement FilesystemStore satisfying the FileStore interface for local filesystem storage, replacing MinIO for simpler deployments. - Atomic writes via temp file + os.Rename (no partial files) - SHA-256 checksum computed on Put via io.MultiWriter - Get/GetVersion return os.File (GetVersion ignores versionID) - Delete is idempotent (no error if file missing) - Copy uses same atomic write pattern - PresignPut returns ErrPresignNotSupported - Ping verifies root directory is writable - Wire NewFilesystemStore in main.go backend switch - 14 unit tests covering all methods including atomicity Closes #127
278 lines
6.4 KiB
Go
278 lines
6.4 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *FilesystemStore {
|
|
t.Helper()
|
|
fs, err := NewFilesystemStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore: %v", err)
|
|
}
|
|
return fs
|
|
}
|
|
|
|
func TestNewFilesystemStore(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sub := filepath.Join(dir, "a", "b")
|
|
fs, err := NewFilesystemStore(sub)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !filepath.IsAbs(fs.root) {
|
|
t.Errorf("root is not absolute: %s", fs.root)
|
|
}
|
|
info, err := os.Stat(sub)
|
|
if err != nil {
|
|
t.Fatalf("root dir missing: %v", err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Error("root is not a directory")
|
|
}
|
|
}
|
|
|
|
func TestPut(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
data := []byte("hello world")
|
|
h := sha256.Sum256(data)
|
|
wantChecksum := hex.EncodeToString(h[:])
|
|
|
|
result, err := fs.Put(ctx, "items/P001/rev1.FCStd", bytes.NewReader(data), int64(len(data)), "application/octet-stream")
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if result.Key != "items/P001/rev1.FCStd" {
|
|
t.Errorf("Key = %q, want %q", result.Key, "items/P001/rev1.FCStd")
|
|
}
|
|
if result.Size != int64(len(data)) {
|
|
t.Errorf("Size = %d, want %d", result.Size, len(data))
|
|
}
|
|
if result.Checksum != wantChecksum {
|
|
t.Errorf("Checksum = %q, want %q", result.Checksum, wantChecksum)
|
|
}
|
|
|
|
// Verify file on disk.
|
|
got, err := os.ReadFile(fs.path("items/P001/rev1.FCStd"))
|
|
if err != nil {
|
|
t.Fatalf("reading file: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Error("file content mismatch")
|
|
}
|
|
}
|
|
|
|
func TestPutAtomicity(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
key := "test/atomic.bin"
|
|
|
|
// Write an initial file.
|
|
if _, err := fs.Put(ctx, key, strings.NewReader("original"), 8, ""); err != nil {
|
|
t.Fatalf("initial Put: %v", err)
|
|
}
|
|
|
|
// Write with a reader that fails partway through.
|
|
failing := io.MultiReader(strings.NewReader("partial"), &errReader{})
|
|
_, err := fs.Put(ctx, key, failing, 100, "")
|
|
if err == nil {
|
|
t.Fatal("expected error from failing reader")
|
|
}
|
|
|
|
// Original file should still be intact.
|
|
got, err := os.ReadFile(fs.path(key))
|
|
if err != nil {
|
|
t.Fatalf("reading file after failed put: %v", err)
|
|
}
|
|
if string(got) != "original" {
|
|
t.Errorf("file content = %q, want %q", got, "original")
|
|
}
|
|
}
|
|
|
|
type errReader struct{}
|
|
|
|
func (e *errReader) Read([]byte) (int, error) {
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
|
|
func TestGet(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
data := []byte("test content")
|
|
|
|
if _, err := fs.Put(ctx, "f.txt", bytes.NewReader(data), int64(len(data)), ""); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
rc, err := fs.Get(ctx, "f.txt")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
got, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Error("content mismatch")
|
|
}
|
|
}
|
|
|
|
func TestGetMissing(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
_, err := fs.Get(context.Background(), "no/such/file")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file")
|
|
}
|
|
}
|
|
|
|
func TestGetVersion(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
data := []byte("versioned")
|
|
|
|
if _, err := fs.Put(ctx, "v.txt", bytes.NewReader(data), int64(len(data)), ""); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
// GetVersion ignores versionID, returns same file.
|
|
rc, err := fs.GetVersion(ctx, "v.txt", "ignored-version-id")
|
|
if err != nil {
|
|
t.Fatalf("GetVersion: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
|
|
got, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Error("content mismatch")
|
|
}
|
|
}
|
|
|
|
func TestDelete(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := fs.Put(ctx, "del.txt", strings.NewReader("x"), 1, ""); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
if err := fs.Delete(ctx, "del.txt"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(fs.path("del.txt")); !os.IsNotExist(err) {
|
|
t.Error("file still exists after delete")
|
|
}
|
|
}
|
|
|
|
func TestDeleteMissing(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
if err := fs.Delete(context.Background(), "no/such/file"); err != nil {
|
|
t.Fatalf("Delete missing file should not error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExists(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
ok, err := fs.Exists(ctx, "nope")
|
|
if err != nil {
|
|
t.Fatalf("Exists: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("Exists returned true for missing file")
|
|
}
|
|
|
|
if _, err := fs.Put(ctx, "yes.txt", strings.NewReader("y"), 1, ""); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
ok, err = fs.Exists(ctx, "yes.txt")
|
|
if err != nil {
|
|
t.Fatalf("Exists: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("Exists returned false for existing file")
|
|
}
|
|
}
|
|
|
|
func TestCopy(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
data := []byte("copy me")
|
|
|
|
if _, err := fs.Put(ctx, "src.bin", bytes.NewReader(data), int64(len(data)), ""); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
|
|
if err := fs.Copy(ctx, "src.bin", "deep/nested/dst.bin"); err != nil {
|
|
t.Fatalf("Copy: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(fs.path("deep/nested/dst.bin"))
|
|
if err != nil {
|
|
t.Fatalf("reading copied file: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Error("copied content mismatch")
|
|
}
|
|
|
|
// Source should still exist.
|
|
if _, err := os.Stat(fs.path("src.bin")); err != nil {
|
|
t.Error("source file missing after copy")
|
|
}
|
|
}
|
|
|
|
func TestPresignPut(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
_, err := fs.PresignPut(context.Background(), "key", 5*60)
|
|
if err != ErrPresignNotSupported {
|
|
t.Errorf("PresignPut error = %v, want ErrPresignNotSupported", err)
|
|
}
|
|
}
|
|
|
|
func TestPing(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
if err := fs.Ping(context.Background()); err != nil {
|
|
t.Fatalf("Ping: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPingBadRoot(t *testing.T) {
|
|
fs := &FilesystemStore{root: "/nonexistent/path/that/should/not/exist"}
|
|
if err := fs.Ping(context.Background()); err == nil {
|
|
t.Fatal("expected Ping to fail with invalid root")
|
|
}
|
|
}
|
|
|
|
func TestPutOverwrite(t *testing.T) {
|
|
fs := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
if _, err := fs.Put(ctx, "ow.txt", strings.NewReader("first"), 5, ""); err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if _, err := fs.Put(ctx, "ow.txt", strings.NewReader("second"), 6, ""); err != nil {
|
|
t.Fatalf("Put overwrite: %v", err)
|
|
}
|
|
|
|
got, _ := os.ReadFile(fs.path("ow.txt"))
|
|
if string(got) != "second" {
|
|
t.Errorf("content = %q, want %q", got, "second")
|
|
}
|
|
}
|