cmd/gitbayd/backup_test.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/cmd/gitbayd/backup_test.go history · blame · raw

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}