cmd/gitbayd/backup_test.go
326 lines · 8394 bytes
1package main
2
3import (
4 "archive/tar"
5 "compress/gzip"
6 "errors"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "testing"
13 "time"
14
15 "filippo.io/age"
16
17 "gitbay.org/gitbay/internal/config"
18)
19
20// members lists the archive's entries by name.
21func members(t *testing.T, path string) []string {
22 t.Helper()
23 f, err := os.Open(path)
24 if err != nil {
25 t.Fatal(err)
26 }
27 defer f.Close()
28 gz, err := gzip.NewReader(f)
29 if err != nil {
30 t.Fatal(err)
31 }
32 var names []string
33 tr := tar.NewReader(gz)
34 for {
35 hdr, err := tr.Next()
36 if err == io.EOF {
37 break
38 }
39 if err != nil {
40 t.Fatal(err)
41 }
42 names = append(names, hdr.Name)
43 }
44 sort.Strings(names)
45 return names
46}
47
48// --db-only is what makes an hourly schedule affordable, so it has to leave
49// the repositories out and still carry a restorable database.
50func TestBackupDBOnlyOmitsRepositories(t *testing.T) {
51 cfg := testConfig(t)
52 root := cfg.Server.Root
53 s, err := openStore(cfg)
54 if err != nil {
55 t.Fatal(err)
56 }
57 s.Close()
58
59 repo := filepath.Join(root, "repos", "krz", "thing.git")
60 if err := os.MkdirAll(repo, 0o750); err != nil {
61 t.Fatal(err)
62 }
63 if err := os.WriteFile(filepath.Join(repo, "HEAD"), []byte("ref: refs/heads/main\n"), 0o640); err != nil {
64 t.Fatal(err)
65 }
66
67 full := filepath.Join(t.TempDir(), "full.tar.gz")
68 if err := runBackup(cfg, full, false); err != nil {
69 t.Fatalf("full backup: %v", err)
70 }
71 dbOnly := filepath.Join(t.TempDir(), "db.tar.gz")
72 if err := runBackup(cfg, dbOnly, true); err != nil {
73 t.Fatalf("db-only backup: %v", err)
74 }
75
76 fullNames := members(t, full)
77 if len(fullNames) < 2 {
78 t.Fatalf("full backup carries only %v", fullNames)
79 }
80 var sawRepo bool
81 for _, n := range fullNames {
82 if n == "repos/krz/thing.git/HEAD" {
83 sawRepo = true
84 }
85 }
86 if !sawRepo {
87 t.Errorf("full backup is missing the repository: %v", fullNames)
88 }
89
90 if got := members(t, dbOnly); len(got) != 1 || got[0] != "gitbay.db" {
91 t.Errorf("db-only backup carries %v, want [gitbay.db]", got)
92 }
93
94 fi, err := os.Stat(dbOnly)
95 if err != nil {
96 t.Fatal(err)
97 }
98 if fi.Size() == 0 {
99 t.Error("db-only backup is empty")
100 }
101}
102
103func TestBackupEncryptedToAgeRecipient(t *testing.T) {
104 cfg := testConfig(t)
105 id, err := age.GenerateX25519Identity()
106 if err != nil {
107 t.Fatal(err)
108 }
109 cfg.Backup.AgeRecipients = []string{id.Recipient().String()}
110 s, err := openStore(cfg)
111 if err != nil {
112 t.Fatal(err)
113 }
114 s.Close()
115
116 out := filepath.Join(t.TempDir(), "b.tar.gz.age")
117 if err := runBackup(cfg, out, true); err != nil {
118 t.Fatal(err)
119 }
120 head := make([]byte, 22)
121 f, err := os.Open(out)
122 if err != nil {
123 t.Fatal(err)
124 }
125 _, err = io.ReadFull(f, head)
126 f.Close()
127 if err != nil {
128 t.Fatal(err)
129 }
130 if string(head) != "age-encryption.org/v1\n" {
131 t.Fatalf("archive is not age-encrypted: %q", head)
132 }
133
134 if err := verifyBackup(out, ""); err == nil || !strings.Contains(err.Error(), "--identity") {
135 t.Fatalf("verify without an identity: %v", err)
136 }
137 idFile := filepath.Join(t.TempDir(), "backup-identity.txt")
138 if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil {
139 t.Fatal(err)
140 }
141 if err := verifyBackup(out, idFile); err != nil {
142 t.Fatalf("verify with the identity: %v", err)
143 }
144 other, err := age.GenerateX25519Identity()
145 if err != nil {
146 t.Fatal(err)
147 }
148 otherFile := filepath.Join(t.TempDir(), "other.txt")
149 if err := os.WriteFile(otherFile, []byte(other.String()+"\n"), 0o600); err != nil {
150 t.Fatal(err)
151 }
152 var noMatch *age.NoIdentityMatchError
153 if err := verifyBackup(out, otherFile); !errors.As(err, &noMatch) {
154 t.Fatalf("verify with another identity: %v, want a no-identity-match error", err)
155 }
156}
157
158// leftovers lists what a backup run left in dir besides the archive.
159func leftovers(t *testing.T, dir string) []string {
160 t.Helper()
161 ents, err := os.ReadDir(dir)
162 if err != nil {
163 t.Fatal(err)
164 }
165 var names []string
166 for _, e := range ents {
167 if strings.HasPrefix(e.Name(), ".") {
168 names = append(names, e.Name())
169 }
170 }
171 return names
172}
173
174// The snapshot directory and the archive's temporary file are removed
175// whether the run succeeds or fails, and a failed run leaves no archive.
176func TestBackupLeavesNoTemporaries(t *testing.T) {
177 cfg := testConfig(t)
178 id, err := age.GenerateX25519Identity()
179 if err != nil {
180 t.Fatal(err)
181 }
182 cfg.Backup.AgeRecipients = []string{id.Recipient().String()}
183 s, err := openStore(cfg)
184 if err != nil {
185 t.Fatal(err)
186 }
187 s.Close()
188
189 dir := t.TempDir()
190 out := filepath.Join(dir, "ok.tar.gz.age")
191 if err := runBackup(cfg, out, false); err != nil {
192 t.Fatal(err)
193 }
194 if got := leftovers(t, dir); len(got) != 0 {
195 t.Errorf("after a successful run: %v", got)
196 }
197 fi, err := os.Stat(out)
198 if err != nil {
199 t.Fatal(err)
200 }
201 if fi.Mode().Perm() != 0o600 {
202 t.Errorf("archive mode %v, want 0600", fi.Mode().Perm())
203 }
204
205 // A file the walk cannot read fails the run after the snapshot and
206 // the temporary archive exist. Root reads a mode-0 file, so the case
207 // needs an unprivileged user.
208 if os.Geteuid() == 0 {
209 t.Log("running as root: skipping the mid-walk failure case")
210 } else {
211 unreadable := filepath.Join(cfg.Server.Root, "unreadable")
212 if err := os.WriteFile(unreadable, []byte("x"), 0o000); err != nil {
213 t.Fatal(err)
214 }
215 failed := filepath.Join(dir, "failed.tar.gz.age")
216 err := runBackup(cfg, failed, false)
217 os.Remove(unreadable)
218 if err == nil {
219 t.Fatal("backup with an unreadable file succeeded")
220 }
221 if _, err := os.Stat(failed); !os.IsNotExist(err) {
222 t.Errorf("failed run left an archive: %v", err)
223 }
224 if got := leftovers(t, dir); len(got) != 0 {
225 t.Errorf("after a failed run: %v", got)
226 }
227 }
228
229 bad := cfg
230 bad.Backup.AgeRecipients = []string{"age1x"}
231 if err := runBackup(bad, filepath.Join(dir, "bad.tar.gz.age"), true); err == nil {
232 t.Fatal("backup with a bad recipient succeeded")
233 }
234 if got := leftovers(t, dir); len(got) != 0 {
235 t.Errorf("after a bad recipient: %v", got)
236 }
237}
238
239func TestBackupRefusesAgeNameWithoutRecipients(t *testing.T) {
240 cfg := testConfig(t)
241 out := filepath.Join(t.TempDir(), "b.tar.gz.age")
242 err := runBackup(cfg, out, true)
243 if err == nil || !strings.Contains(err.Error(), "age_recipients") {
244 t.Fatalf("got %v, want a refusal naming age_recipients", err)
245 }
246}
247
248// A truncated archive fails verification even when the tar stream's end
249// markers survive: gzip's trailer and age's final chunk are checked.
250func TestVerifyRejectsTruncatedArchive(t *testing.T) {
251 cfg := testConfig(t)
252 s, err := openStore(cfg)
253 if err != nil {
254 t.Fatal(err)
255 }
256 s.Close()
257 dir := t.TempDir()
258 plain := filepath.Join(dir, "p.tar.gz")
259 if err := runBackup(cfg, plain, true); err != nil {
260 t.Fatal(err)
261 }
262 id, err := age.GenerateX25519Identity()
263 if err != nil {
264 t.Fatal(err)
265 }
266 enc := cfg
267 enc.Backup.AgeRecipients = []string{id.Recipient().String()}
268 sealed := filepath.Join(dir, "e.tar.gz.age")
269 if err := runBackup(enc, sealed, true); err != nil {
270 t.Fatal(err)
271 }
272 idFile := filepath.Join(dir, "id.txt")
273 if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil {
274 t.Fatal(err)
275 }
276 if err := verifyBackup(plain, ""); err != nil {
277 t.Fatalf("intact plain archive: %v", err)
278 }
279 if err := verifyBackup(sealed, idFile); err != nil {
280 t.Fatalf("intact encrypted archive: %v", err)
281 }
282
283 for _, c := range []struct {
284 src string
285 cut int
286 identity string
287 }{
288 {plain, 1, ""},
289 {sealed, 1, idFile},
290 {sealed, 100, idFile},
291 } {
292 data, err := os.ReadFile(c.src)
293 if err != nil {
294 t.Fatal(err)
295 }
296 short := filepath.Join(dir, "short-"+filepath.Base(c.src))
297 if err := os.WriteFile(short, data[:len(data)-c.cut], 0o600); err != nil {
298 t.Fatal(err)
299 }
300 if err := verifyBackup(short, c.identity); err == nil {
301 t.Errorf("%s cut by %d bytes verified", filepath.Base(c.src), c.cut)
302 }
303 }
304}
305
306func TestArchivePath(t *testing.T) {
307 now := time.Date(2026, 9, 27, 9, 0, 0, 0, time.UTC)
308 plain := testConfig(t)
309 enc := plain
310 enc.Backup.AgeRecipients = []string{"age1x"}
311 for _, c := range []struct {
312 out string
313 cfg config.Config
314 want string
315 }{
316 {"", plain, "gitbay-backup-20260927-090000.tar.gz"},
317 {"", enc, "gitbay-backup-20260927-090000.tar.gz.age"},
318 {"/b/x.tar.gz", enc, "/b/x.tar.gz.age"},
319 {"/b/x.tar.gz.age", enc, "/b/x.tar.gz.age"},
320 {"/b/x.tar.gz", plain, "/b/x.tar.gz"},
321 } {
322 if got := archivePath(c.out, c.cfg, now); got != c.want {
323 t.Errorf("archivePath(%q) = %q, want %q", c.out, got, c.want)
324 }
325 }
326}