1package filesystem
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/bnema/gordon/internal/domain"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17func TestBackupStorage_StoreAndGet(t *testing.T) {
18 storage, err := NewBackupStorage(t.TempDir(), testLogger())
19 require.NoError(t, err)
20
21 now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
22 payload := []byte("backup-content")
23 path, err := storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, now, bytes.NewReader(payload))
24 require.NoError(t, err)
25
26 rc, err := storage.Get(context.Background(), path)
27 require.NoError(t, err)
28 defer rc.Close()
29
30 data, err := io.ReadAll(rc)
31 require.NoError(t, err)
32 assert.Equal(t, payload, data)
33}
34
35func TestBackupStorage_StoreSameSecondBackupsUseDistinctFinalAndTempPaths(t *testing.T) {
36 rootDir := t.TempDir()
37 storage, err := NewBackupStorage(rootDir, testLogger())
38 require.NoError(t, err)
39
40 now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
41 firstPayload := []byte("first-backup")
42 secondPayload := []byte("second-backup")
43
44 firstPath, err := storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, now, bytes.NewReader(firstPayload))
45 require.NoError(t, err)
46 secondPath, err := storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, now, bytes.NewReader(secondPayload))
47 require.NoError(t, err)
48
49 assert.NotEqual(t, firstPath, secondPath)
50 assert.NoFileExists(t, firstPath+".tmp")
51 assert.NoFileExists(t, secondPath+".tmp")
52
53 firstRead, err := storage.Get(context.Background(), firstPath)
54 require.NoError(t, err)
55 defer firstRead.Close()
56 firstData, err := io.ReadAll(firstRead)
57 require.NoError(t, err)
58 assert.Equal(t, firstPayload, firstData)
59
60 secondRead, err := storage.Get(context.Background(), secondPath)
61 require.NoError(t, err)
62 defer secondRead.Close()
63 secondData, err := io.ReadAll(secondRead)
64 require.NoError(t, err)
65 assert.Equal(t, secondPayload, secondData)
66}
67
68func TestBackupStorage_PreservesCanonicalIdentityAndSeparatesSameNamedDatabases(t *testing.T) {
69 storage, err := NewBackupStorage(t.TempDir(), testLogger())
70 require.NoError(t, err)
71
72 started := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
73 first, err := storage.Store(context.Background(), "shop", "api", "main", domain.ScheduleDaily, started, bytes.NewReader([]byte("api")))
74 require.NoError(t, err)
75 second, err := storage.Store(context.Background(), "shop", "worker", "main", domain.ScheduleDaily, started, bytes.NewReader([]byte("worker")))
76 require.NoError(t, err)
77 assert.NotEqual(t, first, second)
78
79 jobs, err := storage.List(context.Background(), "shop", nil)
80 require.NoError(t, err)
81 require.Len(t, jobs, 2)
82 assert.ElementsMatch(t, []string{"api", "worker"}, []string{jobs[0].Service, jobs[1].Service})
83 for _, job := range jobs {
84 assert.Equal(t, "shop", job.App)
85 assert.Equal(t, "main", job.DBName)
86 }
87}
88
89func TestBackupStorage_DoesNotMergePreCutoverRecords(t *testing.T) {
90 root := t.TempDir()
91 storage, err := NewBackupStorage(root, testLogger())
92 require.NoError(t, err)
93
94 legacyDir := filepath.Join(root, "shop", "main", "daily")
95 require.NoError(t, os.MkdirAll(legacyDir, 0750))
96 require.NoError(t, os.WriteFile(filepath.Join(legacyDir, "20260912T120000Z.bak"), []byte("legacy"), 0600))
97 _, err = storage.Store(context.Background(), "shop", "api", "main", domain.ScheduleDaily, time.Now(), bytes.NewReader([]byte("current")))
98 require.NoError(t, err)
99
100 jobs, err := storage.List(context.Background(), "shop", nil)
101 require.NoError(t, err)
102 require.Len(t, jobs, 1)
103 assert.Equal(t, "api", jobs[0].Service)
104}
105
106func TestBackupStorage_ListWithScheduleFilter(t *testing.T) {
107 storage, err := NewBackupStorage(t.TempDir(), testLogger())
108 require.NoError(t, err)
109
110 base := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
111 _, err = storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, base, bytes.NewReader([]byte("d1")))
112 require.NoError(t, err)
113 _, err = storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, base.Add(time.Hour), bytes.NewReader([]byte("d2")))
114 require.NoError(t, err)
115 _, err = storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleWeekly, base.Add(2*time.Hour), bytes.NewReader([]byte("w1")))
116 require.NoError(t, err)
117
118 schedule := domain.ScheduleDaily
119 jobs, err := storage.List(context.Background(), "app.example.com", &schedule)
120 require.NoError(t, err)
121 require.Len(t, jobs, 2)
122 assert.Equal(t, domain.ScheduleDaily, jobs[0].Schedule)
123 assert.Equal(t, domain.ScheduleDaily, jobs[1].Schedule)
124 assert.True(t, jobs[0].StartedAt.After(jobs[1].StartedAt))
125}
126
127func TestBackupStorage_Delete(t *testing.T) {
128 storage, err := NewBackupStorage(t.TempDir(), testLogger())
129 require.NoError(t, err)
130
131 path, err := storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data")))
132 require.NoError(t, err)
133
134 err = storage.Delete(context.Background(), path)
135 require.NoError(t, err)
136
137 _, err = storage.Get(context.Background(), path)
138 assert.Error(t, err)
139}
140
141func TestBackupStorage_ApplyRetention(t *testing.T) {
142 storage, err := NewBackupStorage(t.TempDir(), testLogger())
143 require.NoError(t, err)
144
145 base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC)
146 for i := range 4 {
147 _, err := storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data")))
148 require.NoError(t, err)
149 }
150
151 deleted, err := storage.ApplyRetention(context.Background(), "app.example.com", domain.RetentionPolicy{Daily: 2})
152 require.NoError(t, err)
153 assert.Equal(t, 2, deleted)
154
155 schedule := domain.ScheduleDaily
156 jobs, err := storage.List(context.Background(), "app.example.com", &schedule)
157 require.NoError(t, err)
158 assert.Len(t, jobs, 2)
159}
160
161func TestBackupStorage_StoreSanitizesDotOnlyPathComponents(t *testing.T) {
162 storage, err := NewBackupStorage(t.TempDir(), testLogger())
163 require.NoError(t, err)
164
165 path, err := storage.Store(context.Background(), "..", "service", "...", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data")))
166 require.NoError(t, err)
167 assert.NotContains(t, path, "..")
168}
169
170func TestBackupStorage_StoreSanitizesSchedulePathComponent(t *testing.T) {
171 rootDir := t.TempDir()
172 storage, err := NewBackupStorage(rootDir, testLogger())
173 require.NoError(t, err)
174
175 path, err := storage.Store(context.Background(), "app.example.com", "postgres", "postgres", domain.BackupSchedule("../../escape"), time.Now().UTC(), bytes.NewReader([]byte("data")))
176 require.NoError(t, err)
177
178 rel, err := filepath.Rel(rootDir, path)
179 require.NoError(t, err)
180 assert.NotEqual(t, "..", rel)
181 assert.NotContains(t, rel, "../")
182 assert.NotContains(t, rel, "..\\")
183}