1package s3
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "sort"
8 "testing"
9 "time"
10
11 "github.com/aws/aws-sdk-go-v2/aws"
12 "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
13 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
14 awss3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "github.com/bnema/gordon/internal/domain"
19)
20
21type fakeVolumeS3Client struct {
22 objects map[string][]byte
23 metadata map[string]map[string]string
24 deleted []string
25}
26
27func newFakeVolumeS3Client() *fakeVolumeS3Client {
28 return &fakeVolumeS3Client{objects: make(map[string][]byte), metadata: make(map[string]map[string]string)}
29}
30
31func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
32 data := f.objects[aws.ToString(params.Key)]
33 return &awss3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil
34}
35
36func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
37 return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
38}
39
40func (f *fakeVolumeS3Client) ListObjectsV2(_ context.Context, params *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
41 prefix := aws.ToString(params.Prefix)
42 keys := make([]string, 0)
43 for key := range f.objects {
44 if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
45 keys = append(keys, key)
46 }
47 }
48 sort.Strings(keys)
49 contents := make([]awss3types.Object, 0, len(keys))
50 for _, key := range keys {
51 size := int64(len(f.objects[key]))
52 contents = append(contents, awss3types.Object{Key: aws.String(key), Size: aws.Int64(size)})
53 }
54 return &awss3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(false)}, nil
55}
56
57func (f *fakeVolumeS3Client) DeleteObject(_ context.Context, params *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
58 key := aws.ToString(params.Key)
59 delete(f.objects, key)
60 f.deleted = append(f.deleted, key)
61 return &awss3.DeleteObjectOutput{}, nil
62}
63
64type fakeVolumeUploader struct {
65 client *fakeVolumeS3Client
66 last *transfermanager.UploadObjectInput
67}
68
69func (f *fakeVolumeUploader) UploadObject(_ context.Context, input *transfermanager.UploadObjectInput, _ ...func(*transfermanager.Options)) (*transfermanager.UploadObjectOutput, error) {
70 data, err := io.ReadAll(input.Body)
71 if err != nil {
72 return nil, err
73 }
74 key := aws.ToString(input.Key)
75 f.client.objects[key] = data
76 f.client.metadata[key] = input.Metadata
77 f.last = input
78 return &transfermanager.UploadObjectOutput{Key: input.Key}, nil
79}
80
81func TestVolumeBackupStorageStoreListGet(t *testing.T) {
82 client := newFakeVolumeS3Client()
83 uploader := &fakeVolumeUploader{client: client}
84 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
85 S3Bucket: "gordon-backups",
86 S3Prefix: "/prod/gordon/",
87 }, client, uploader)
88 started := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC)
89
90 artifact, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
91 ID: "job/1",
92 Domain: "app.example.com",
93 ContainerName: "app",
94 VolumeName: "gordon-app-data",
95 MountPath: "/data",
96 StartedAt: started,
97 Metadata: map[string]string{"compression": string(domain.VolumeBackupCompressionZstd)},
98 }, bytes.NewReader([]byte("archive")))
99 require.NoError(t, err)
100
101 assert.Equal(t, "s3://gordon-backups/prod/gordon/domains/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)
102 assert.Equal(t, "application/zstd", aws.ToString(uploader.last.ContentType))
103
104 jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
105 require.NoError(t, err)
106 require.Len(t, jobs, 1)
107 assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
108 assert.Equal(t, "app", jobs[0].ContainerName)
109 assert.Equal(t, "/data", jobs[0].MountPath)
110 assert.Equal(t, string(domain.VolumeBackupCompressionZstd), jobs[0].Metadata["compression"])
111 assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
112 assert.Equal(t, artifact, jobs[0].ArtifactRef)
113
114 rc, err := storage.GetVolumeArchive(context.Background(), artifact)
115 require.NoError(t, err)
116 defer rc.Close()
117 data, err := io.ReadAll(rc)
118 require.NoError(t, err)
119 assert.Equal(t, []byte("archive"), data)
120}
121
122func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
123 client := newFakeVolumeS3Client()
124 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
125 keys := []string{
126 "domains/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst",
127 "domains/app.example.com/volumes/gordon-data/20260619T020000Z-b.tar.zst",
128 "domains/app.example.com/volumes/gordon-data/20260619T030000Z-c.tar.zst",
129 "domains/app.example.com/volumes/gordon-data/not-a-backup.tar.zst",
130 }
131 for _, key := range keys {
132 client.objects[key] = []byte("x")
133 }
134
135 deleted, err := storage.ApplyVolumeRetention(context.Background(), "app.example.com", domain.VolumeBackupRetentionPolicy{Keep: 2})
136 require.NoError(t, err)
137
138 assert.Equal(t, 1, deleted)
139 assert.Equal(t, []string{"domains/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst"}, client.deleted)
140 assert.Contains(t, client.objects, "domains/app.example.com/volumes/gordon-data/not-a-backup.tar.zst")
141}
142
143func TestVolumeBackupStorageRejectsWrongBucketArtifact(t *testing.T) {
144 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "expected"}, newFakeVolumeS3Client(), nil)
145
146 _, err := storage.GetVolumeArchive(context.Background(), "s3://other/key")
147
148 require.Error(t, err)
149 assert.Contains(t, err.Error(), "does not match")
150}