From 15ae8c03dfe9d5e9f577721550d02e6a2260dd40 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 19:10:28 +0000 Subject: [PATCH] Add tests for internal/storage URL parsing and the file backend (closes #66) internal/storage had no tests. This adds table-driven coverage for ParseStorageURL (each scheme, query parameters, missing components, an unknown scheme, and the fields that decide which backend is built) and a backend-agnostic Storer conformance suite run against the file:// backend over a temp directory: put, get, stat, list with prefix filtering, overwrite, delete, delete-of-missing, and not-found on Get and Stat. Tests only; no production code changed and no defect surfaced. New files (url_parse_test.go, file_backend_test.go) avoid colliding with the s3_test.go and file-backend work in flight on other branches. The conformance helper takes a constructor, so s3:// and rclone:// can adopt it once their harness is wired. Model: opus-4-8 --- internal/storage/file_backend_test.go | 214 ++++++++++++++++++++++++++ internal/storage/url_parse_test.go | 110 +++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 internal/storage/file_backend_test.go create mode 100644 internal/storage/url_parse_test.go diff --git a/internal/storage/file_backend_test.go b/internal/storage/file_backend_test.go new file mode 100644 index 0000000..c504e7e --- /dev/null +++ b/internal/storage/file_backend_test.go @@ -0,0 +1,214 @@ +package storage_test + +import ( + "bytes" + "context" + "errors" + "io" + "reflect" + "sort" + "testing" + + "sneak.berlin/go/vaultik/internal/storage" +) + +// newFileStorer builds a file:// backend rooted at a fresh temp directory. +// +//nolint:ireturn // conformance runs against the Storer interface by design +func newFileStorer(t *testing.T) storage.Storer { + t.Helper() + + s, err := storage.NewFileStorer(t.TempDir()) + if err != nil { + t.Fatalf("NewFileStorer: %v", err) + } + + return s +} + +// TestFileStorer runs the Storer contract against the file:// backend. +// The conformance helper is backend-agnostic, so a new backend inherits +// this coverage by passing its own constructor. +func TestFileStorer(t *testing.T) { + t.Parallel() + runStorerConformance(t, newFileStorer) +} + +// runStorerConformance exercises the public Storer contract: round-trip, +// stat, list, overwrite, delete, and not-found behaviour. Each section +// uses its own backend instance so ordering never matters. +func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) { + t.Helper() + + conformanceRoundTrip(t, newStorer(t)) + conformanceOverwrite(t, newStorer(t)) + conformanceList(t, newStorer(t)) + conformanceDelete(t, newStorer(t)) + conformanceNotFound(t, newStorer(t)) +} + +// conformanceRoundTrip stores a nested key, then reads it back and stats it. +func conformanceRoundTrip(t *testing.T, s storage.Storer) { + t.Helper() + + ctx := context.Background() + key := "blobs/aa/bb/object.bin" + want := []byte("round-trip payload") + + err := s.Put(ctx, key, bytes.NewReader(want)) + if err != nil { + t.Fatalf("Put: %v", err) + } + + got := getBytes(t, s, key) + if !bytes.Equal(got, want) { + t.Errorf("Get returned %q, want %q", got, want) + } + + info, err := s.Stat(ctx, key) + if err != nil { + t.Fatalf("Stat: %v", err) + } + + if info.Key != key { + t.Errorf("Stat key = %q, want %q", info.Key, key) + } + + if info.Size != int64(len(want)) { + t.Errorf("Stat size = %d, want %d", info.Size, len(want)) + } +} + +// conformanceOverwrite checks that a second Put replaces the first. +func conformanceOverwrite(t *testing.T, s storage.Storer) { + t.Helper() + + ctx := context.Background() + key := "meta/snapshot.json" + + err := s.Put(ctx, key, bytes.NewReader([]byte("first"))) + if err != nil { + t.Fatalf("first Put: %v", err) + } + + want := []byte("second and longer payload") + + err = s.Put(ctx, key, bytes.NewReader(want)) + if err != nil { + t.Fatalf("second Put: %v", err) + } + + got := getBytes(t, s, key) + if !bytes.Equal(got, want) { + t.Errorf("after overwrite Get returned %q, want %q", got, want) + } +} + +// conformanceList checks prefix filtering and the empty result for a +// prefix that matches nothing. +func conformanceList(t *testing.T, s storage.Storer) { + t.Helper() + + ctx := context.Background() + keys := []string{"blobs/aa/one", "blobs/bb/two", "meta/three"} + + for _, k := range keys { + err := s.Put(ctx, k, bytes.NewReader([]byte("data"))) + if err != nil { + t.Fatalf("Put %q: %v", k, err) + } + } + + if got := listSorted(t, s, ""); !reflect.DeepEqual(got, keys) { + t.Errorf("List(\"\") = %v, want %v", got, keys) + } + + wantBlobs := []string{"blobs/aa/one", "blobs/bb/two"} + if got := listSorted(t, s, "blobs/"); !reflect.DeepEqual(got, wantBlobs) { + t.Errorf("List(\"blobs/\") = %v, want %v", got, wantBlobs) + } + + if got := listSorted(t, s, "absent/"); len(got) != 0 { + t.Errorf("List(\"absent/\") = %v, want empty", got) + } +} + +// conformanceDelete checks that Delete removes an object and that deleting +// a missing key is not an error. +func conformanceDelete(t *testing.T, s storage.Storer) { + t.Helper() + + ctx := context.Background() + key := "blobs/cc/gone.bin" + + err := s.Put(ctx, key, bytes.NewReader([]byte("temporary"))) + if err != nil { + t.Fatalf("Put: %v", err) + } + + err = s.Delete(ctx, key) + if err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err = s.Get(ctx, key) + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get after Delete error = %v, want ErrNotFound", err) + } + + err = s.Delete(ctx, key) + if err != nil { + t.Errorf("Delete of missing key = %v, want nil", err) + } +} + +// conformanceNotFound checks Get and Stat on an absent key. +func conformanceNotFound(t *testing.T, s storage.Storer) { + t.Helper() + + ctx := context.Background() + key := "never/written" + + _, err := s.Get(ctx, key) + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Get error = %v, want ErrNotFound", err) + } + + _, err = s.Stat(ctx, key) + if !errors.Is(err, storage.ErrNotFound) { + t.Errorf("Stat error = %v, want ErrNotFound", err) + } +} + +// getBytes reads a key fully and closes the reader. +func getBytes(t *testing.T, s storage.Storer, key string) []byte { + t.Helper() + + rc, err := s.Get(context.Background(), key) + if err != nil { + t.Fatalf("Get %q: %v", key, err) + } + + defer func() { _ = rc.Close() }() + + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read %q: %v", key, err) + } + + return data +} + +// listSorted returns the keys under a prefix in a stable order. +func listSorted(t *testing.T, s storage.Storer, prefix string) []string { + t.Helper() + + keys, err := s.List(context.Background(), prefix) + if err != nil { + t.Fatalf("List %q: %v", prefix, err) + } + + sort.Strings(keys) + + return keys +} diff --git a/internal/storage/url_parse_test.go b/internal/storage/url_parse_test.go new file mode 100644 index 0000000..d49bc5c --- /dev/null +++ b/internal/storage/url_parse_test.go @@ -0,0 +1,110 @@ +package storage_test + +import ( + "errors" + "reflect" + "testing" + + "sneak.berlin/go/vaultik/internal/storage" +) + +// TestParseStorageURLValid checks that each supported scheme parses into +// the expected fields, since those fields decide which backend is built. +func TestParseStorageURLValid(t *testing.T) { + t.Parallel() + + const bucket = "mybucket" + + cases := []struct { + name string + raw string + want *storage.URL + }{ + { + name: "file absolute path", + raw: "file:///var/backups/vaultik", + want: &storage.URL{Scheme: "file", Prefix: "/var/backups/vaultik"}, + }, + { + name: "s3 bucket and prefix, ssl defaults on", + raw: "s3://mybucket/backups/host", + want: &storage.URL{ + Scheme: "s3", Bucket: bucket, + Prefix: "backups/host", UseSSL: true, + }, + }, + { + name: "s3 bucket only", + raw: "s3://mybucket", + want: &storage.URL{Scheme: "s3", Bucket: bucket, UseSSL: true}, + }, + { + name: "s3 with endpoint, region, ssl off", + raw: "s3://mybucket?endpoint=minio.example.com®ion=us-west-2&ssl=false", + want: &storage.URL{ + Scheme: "s3", Bucket: bucket, + Endpoint: "minio.example.com", Region: "us-west-2", UseSSL: false, + }, + }, + { + name: "rclone remote and path", + raw: "rclone://gdrive/backups/host", + want: &storage.URL{ + Scheme: "rclone", RcloneRemote: "gdrive", Prefix: "backups/host", + }, + }, + { + name: "rclone remote only", + raw: "rclone://gdrive", + want: &storage.URL{Scheme: "rclone", RcloneRemote: "gdrive"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := storage.ParseStorageURL(tc.raw) + if err != nil { + t.Fatalf("ParseStorageURL(%q) returned error: %v", tc.raw, err) + } + + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("ParseStorageURL(%q) = %+v, want %+v", tc.raw, got, tc.want) + } + }) + } +} + +// TestParseStorageURLErrors checks that empty, missing, and unknown-scheme +// inputs fail with the documented sentinel errors instead of parsing to a +// wrong destination. +func TestParseStorageURLErrors(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + raw string + wantErr error + }{ + {"empty url", "", storage.ErrEmptyStorageURL}, + {"file empty path", "file://", storage.ErrEmptyFilePath}, + {"s3 missing bucket", "s3://", storage.ErrMissingBucket}, + {"s3 missing bucket with path", "s3:///justprefix", storage.ErrMissingBucket}, + {"rclone missing remote", "rclone://", storage.ErrMissingRemote}, + {"unknown scheme", "gs://bucket/x", storage.ErrUnsupportedScheme}, + {"no scheme", "/local/path", storage.ErrUnsupportedScheme}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := storage.ParseStorageURL(tc.raw) + if !errors.Is(err, tc.wantErr) { + t.Errorf("ParseStorageURL(%q) error = %v, want %v", + tc.raw, err, tc.wantErr) + } + }) + } +}