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 heads int
26}
27
28func newFakeVolumeS3Client() *fakeVolumeS3Client {
29 return &fakeVolumeS3Client{objects: make(map[string][]byte), metadata: make(map[string]map[string]string)}
30}
31
32func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
33 data := f.objects[aws.ToString(params.Key)]
34 return &awss3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil
35}
36
37func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
38 f.heads++
39 return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
40}
41
42func (f *fakeVolumeS3Client) ListObjectsV2(_ context.Context, params *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
43 prefix := aws.ToString(params.Prefix)
44 keys := make([]string, 0)
45 for key := range f.objects {
46 if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
47 keys = append(keys, key)
48 }
49 }
50 sort.Strings(keys)
51 contents := make([]awss3types.Object, 0, len(keys))
52 for _, key := range keys {
53 size := int64(len(f.objects[key]))
54 contents = append(contents, awss3types.Object{Key: aws.String(key), Size: aws.Int64(size)})
55 }
56 return &awss3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(false)}, nil
57}
58
59func (f *fakeVolumeS3Client) DeleteObject(_ context.Context, params *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
60 key := aws.ToString(params.Key)
61 delete(f.objects, key)
62 f.deleted = append(f.deleted, key)
63 return &awss3.DeleteObjectOutput{}, nil
64}
65
66type fakeVolumeUploader struct {
67 client *fakeVolumeS3Client
68 last *transfermanager.UploadObjectInput
69}
70
71func (f *fakeVolumeUploader) UploadObject(_ context.Context, input *transfermanager.UploadObjectInput, _ ...func(*transfermanager.Options)) (*transfermanager.UploadObjectOutput, error) {
72 data, err := io.ReadAll(input.Body)
73 if err != nil {
74 return nil, err
75 }
76 key := aws.ToString(input.Key)
77 f.client.objects[key] = data
78 f.client.metadata[key] = input.Metadata
79 f.last = input
80 return &transfermanager.UploadObjectOutput{Key: input.Key}, nil
81}
82
83func TestVolumeBackupStorageStoreListGet(t *testing.T) {
84 client := newFakeVolumeS3Client()
85 uploader := &fakeVolumeUploader{client: client}
86 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
87 S3Bucket: "gordon-backups",
88 S3Prefix: "/prod/gordon/",
89 }, client, uploader)
90 started := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC)
91
92 artifact, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
93 ID: "job/1",
94 App: "app.example.com",
95 Service: "api",
96 ContainerName: "app",
97 VolumeName: "gordon-app-data",
98 MountPath: "/data",
99 StartedAt: started,
100 Metadata: map[string]string{"compression": string(domain.VolumeBackupCompressionZstd)},
101 }, bytes.NewReader([]byte("archive")))
102 require.NoError(t, err)
103
104 assert.Equal(t, "s3://gordon-backups/prod/gordon/apps/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)
105 assert.Equal(t, "application/zstd", aws.ToString(uploader.last.ContentType))
106 assert.Empty(t, uploader.last.StorageClass, "no storage class keeps the bucket default")
107
108 jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
109 require.NoError(t, err)
110 require.Len(t, jobs, 1)
111 assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
112 assert.Equal(t, "api", jobs[0].Service)
113 assert.Equal(t, "app", jobs[0].ContainerName)
114 assert.Equal(t, "/data", jobs[0].MountPath)
115 assert.Equal(t, string(domain.VolumeBackupCompressionZstd), jobs[0].Metadata["compression"])
116 assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
117 assert.Equal(t, artifact, jobs[0].ArtifactRef)
118
119 rc, err := storage.GetVolumeArchive(context.Background(), artifact)
120 require.NoError(t, err)
121 defer rc.Close()
122 data, err := io.ReadAll(rc)
123 require.NoError(t, err)
124 assert.Equal(t, []byte("archive"), data)
125}
126
127func TestVolumeBackupStorageSendsConfiguredStorageClass(t *testing.T) {
128 client := newFakeVolumeS3Client()
129 uploader := &fakeVolumeUploader{client: client}
130 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
131 S3Bucket: "bucket",
132 S3StorageClass: "ONEZONE_IA",
133 }, client, uploader)
134
135 _, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
136 ID: "job",
137 App: "app",
138 VolumeName: "data",
139 StartedAt: time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC),
140 }, bytes.NewReader([]byte("archive")))
141 require.NoError(t, err)
142
143 assert.Equal(t, "ONEZONE_IA", string(uploader.last.StorageClass))
144}
145
146// TestVolumeBackupStorageRetentionMixesLongAndShortIDs proves archives
147// written with the former timestamped job IDs stay listed and age out
148// under retention next to archives with short IDs.
149func TestVolumeBackupStorageRetentionMixesLongAndShortIDs(t *testing.T) {
150 client := newFakeVolumeS3Client()
151 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, &fakeVolumeUploader{client: client})
152 longID := "gordon/apps/forgejo/volumes/forgejo-data/20261017T052537Z-2026-10-17T05_25_37.725487298Z-7cddcd8f.tar.gz"
153 short1 := "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-1a2b3c4d.tar.gz"
154 short2 := "gordon/apps/forgejo/volumes/forgejo-data/20261019T013000Z-5e6f7a8b.tar.gz"
155 for _, key := range []string{longID, short1, short2} {
156 client.objects[key] = []byte("x")
157 }
158
159 jobs, err := storage.ListVolumeArchives(context.Background(), "forgejo")
160 require.NoError(t, err)
161 require.Len(t, jobs, 3)
162 assert.Equal(t, "5e6f7a8b", jobs[0].ID)
163 assert.Equal(t, "2026-10-17T05_25_37.725487298Z-7cddcd8f", jobs[2].ID)
164
165 deleted, err := storage.ApplyVolumeRetention(context.Background(), "forgejo", domain.VolumeBackupRetentionPolicy{Keep: 2})
166 require.NoError(t, err)
167 assert.Equal(t, 1, deleted)
168 assert.Equal(t, []string{longID}, client.deleted)
169}
170
171// TestVolumeBackupStorageLatestVolumeArchives proves the newest archive of
172// each app volume is selected from the listing alone, with no per-object
173// metadata request.
174func TestVolumeBackupStorageLatestVolumeArchives(t *testing.T) {
175 client := newFakeVolumeS3Client()
176 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, nil)
177 for key, data := range map[string]string{
178 "gordon/apps/forgejo/volumes/forgejo-data/20261017T013000Z-1a2b3c4d.tar.gz": "old",
179 "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-5e6f7a8b.tar.gz": "newest",
180 "gordon/apps/forgejo/volumes/postgres-data/20261016T013000Z-0a0b0c0d.tar.gz": "pg",
181 "gordon/apps/ermite/volumes/forgejo-data/20261015T013000Z-9a9b9c9d.tar.gz": "other-app",
182 } {
183 client.objects[key] = []byte(data)
184 }
185
186 jobs, err := storage.LatestVolumeArchives(context.Background())
187 require.NoError(t, err)
188 assert.Equal(t, 0, client.heads)
189
190 got := map[string]domain.VolumeBackupJob{}
191 for _, job := range jobs {
192 got[job.App+"/"+job.VolumeName] = job
193 }
194 require.Len(t, got, 3)
195 newest := got["forgejo/forgejo-data"]
196 assert.Equal(t, "5e6f7a8b", newest.ID)
197 assert.Equal(t, int64(len("newest")), newest.SizeBytes)
198 assert.Equal(t, time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC), newest.StartedAt)
199 assert.Contains(t, got, "forgejo/postgres-data")
200 assert.Contains(t, got, "ermite/forgejo-data")
201}
202
203func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
204 client := newFakeVolumeS3Client()
205 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
206 keys := []string{
207 "apps/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst",
208 "apps/app.example.com/volumes/gordon-data/20260619T020000Z-b.tar.zst",
209 "apps/app.example.com/volumes/gordon-data/20260619T030000Z-c.tar.zst",
210 "apps/app.example.com/volumes/gordon-data/not-a-backup.tar.zst",
211 }
212 for _, key := range keys {
213 client.objects[key] = []byte("x")
214 }
215
216 deleted, err := storage.ApplyVolumeRetention(context.Background(), "app.example.com", domain.VolumeBackupRetentionPolicy{Keep: 2})
217 require.NoError(t, err)
218
219 assert.Equal(t, 1, deleted)
220 assert.Equal(t, []string{"apps/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst"}, client.deleted)
221 assert.Contains(t, client.objects, "apps/app.example.com/volumes/gordon-data/not-a-backup.tar.zst")
222}
223
224func TestVolumeBackupStorageRejectsWrongBucketArtifact(t *testing.T) {
225 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "expected"}, newFakeVolumeS3Client(), nil)
226
227 _, err := storage.GetVolumeArchive(context.Background(), "s3://other/key")
228
229 require.Error(t, err)
230 assert.Contains(t, err.Error(), "does not match")
231}