204 lines
7.4 KiB
Go
204 lines
7.4 KiB
Go
package offsite
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/dbbackup"
|
|
)
|
|
|
|
var dbT0 = time.Date(2026, 9, 20, 3, 30, 0, 0, time.UTC)
|
|
|
|
// dbBundle is a bundle as dbbackup.Backup lays it out: MANIFEST.json, then a
|
|
// dump of dumpSize random bytes.
|
|
func dbBundle(t *testing.T, created time.Time, counts *dbbackup.Counts, dumpSize int) []byte {
|
|
t.Helper()
|
|
dump := make([]byte, dumpSize)
|
|
rand.Read(dump)
|
|
sum := sha256.Sum256(dump)
|
|
m := dbbackup.Manifest{
|
|
Format: 1, CreatedAt: created, Label: dbbackup.LabelDaily, FelisVersion: "v1.2.3", SchemaVersion: 21, Counts: counts,
|
|
Files: []dbbackup.ManifestEntry{{Name: "db.dump", Size: int64(dumpSize), SHA256: hex.EncodeToString(sum[:]), Mode: 0o600}},
|
|
}
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
for _, f := range []struct {
|
|
name string
|
|
data []byte
|
|
}{{"MANIFEST.json", raw}, {"db.dump", dump}} {
|
|
if err := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0o600, Size: int64(len(f.data)), ModTime: created, Typeflag: tar.TypeReg}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tw.Write(f.data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// putDB seals a bundle taken daysAgo days before dbT0 into b and returns its name.
|
|
func putDB(t *testing.T, b *memBucket, key []byte, daysAgo int, counts *dbbackup.Counts) string {
|
|
t.Helper()
|
|
created := dbT0.AddDate(0, 0, -daysAgo)
|
|
name := dbbackup.BundleName(created, dbbackup.LabelDaily)
|
|
b.objs[DBKey(name)] = seal(t, dbBundle(t, created, counts, 100), key)
|
|
return name
|
|
}
|
|
|
|
func TestPeekDBReadsOnlyTheManifest(t *testing.T) {
|
|
key := testKey(t)
|
|
b := newMemBucket()
|
|
name := dbbackup.BundleName(dbT0, dbbackup.LabelDaily)
|
|
sealed := seal(t, dbBundle(t, dbT0, &dbbackup.Counts{Users: 4, Servers: 2}, 5*segmentSize), key)
|
|
|
|
// The last segment is damaged: a whole download fails, the peek never gets there.
|
|
tail := bytes.Clone(sealed)
|
|
tail[len(tail)-1] ^= 1
|
|
b.objs[DBKey(name)] = tail
|
|
m, err := PeekDB(context.Background(), b, key, name)
|
|
if err != nil {
|
|
t.Fatalf("PeekDB read past the manifest: %v", err)
|
|
}
|
|
if !m.CreatedAt.Equal(dbT0) || m.FelisVersion != "v1.2.3" || m.Counts == nil || *m.Counts != (dbbackup.Counts{Users: 4, Servers: 2}) {
|
|
t.Errorf("manifest = %+v", m)
|
|
}
|
|
|
|
// The segment holding the manifest is authenticated like any other.
|
|
head := bytes.Clone(sealed)
|
|
head[headerSize+10] ^= 1
|
|
b.objs[DBKey(name)] = head
|
|
if _, err := PeekDB(context.Background(), b, key, name); !errors.Is(err, ErrAuth) {
|
|
t.Errorf("damaged manifest segment: err = %v, want ErrAuth", err)
|
|
}
|
|
b.objs[DBKey(name)] = sealed
|
|
if _, err := PeekDB(context.Background(), b, testKey(t), name); !errors.Is(err, ErrAuth) {
|
|
t.Errorf("another key: err = %v, want ErrAuth", err)
|
|
}
|
|
b.objs[DBKey(name)] = seal(t, []byte("not a bundle"), key)
|
|
if _, err := PeekDB(context.Background(), b, key, name); err == nil || !strings.Contains(err.Error(), "not a felis database bundle") {
|
|
t.Errorf("sealed junk: err = %v", err)
|
|
}
|
|
if _, err := PeekDB(context.Background(), b, key, "felis-db-20260101T000000Z-daily.tar"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("missing bundle: err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestChooseDB(t *testing.T) {
|
|
c := func(users, servers int) *dbbackup.Counts { return &dbbackup.Counts{Users: users, Servers: servers} }
|
|
type put struct {
|
|
daysAgo int
|
|
counts *dbbackup.Counts
|
|
}
|
|
for _, tc := range []struct {
|
|
what string
|
|
bundles []put
|
|
pick int // index into bundles, or -1 for a refusal
|
|
named []int // bundles the refusal names, in order
|
|
unnamed []int // bundles it must leave out
|
|
says []string // what else the refusal says
|
|
}{
|
|
{what: "the newest holds data", bundles: []put{{1, c(5, 3)}, {0, c(5, 2)}}, pick: 1},
|
|
{what: "the newest predates counts", bundles: []put{{1, c(5, 3)}, {0, nil}}, pick: 1},
|
|
{what: "only the owner and a server", bundles: []put{{1, c(5, 3)}, {0, c(1, 1)}}, pick: 1},
|
|
{what: "a new install with nothing older", bundles: []put{{0, c(0, 0)}}, pick: 0},
|
|
{what: "a new install over older empty ones", bundles: []put{{2, c(0, 0)}, {1, c(1, 0)}, {0, c(1, 0)}}, pick: 2},
|
|
{
|
|
what: "a rebuilt host's first backup", bundles: []put{{3, c(5, 3)}, {2, c(0, 0)}, {1, c(6, 3)}, {0, c(0, 0)}},
|
|
pick: -1, named: []int{2, 0}, unnamed: []int{1}, says: []string{"0 accounts, 0 servers", "6 accounts, 3 servers", "5 accounts, 3 servers"},
|
|
},
|
|
{
|
|
what: "the rebuilt host's owner already set up", bundles: []put{{1, c(2, 0)}, {0, c(1, 0)}},
|
|
pick: -1, named: []int{0}, says: []string{"1 account, 0 servers", "2 accounts, 0 servers"},
|
|
},
|
|
{
|
|
what: "servers the owner made before the loss", bundles: []put{{1, c(1, 2)}, {0, c(1, 0)}},
|
|
pick: -1, named: []int{0}, says: []string{"1 account, 2 servers"},
|
|
},
|
|
{
|
|
what: "older bundles from before counts", bundles: []put{{1, nil}, {0, c(0, 0)}},
|
|
pick: -1, named: []int{0}, says: []string{"(not recorded)"},
|
|
},
|
|
{
|
|
what: "at most three named", bundles: []put{{5, c(9, 9)}, {4, c(8, 8)}, {3, c(7, 7)}, {2, c(6, 6)}, {1, c(0, 0)}},
|
|
pick: -1, named: []int{3, 2, 1}, unnamed: []int{0}, says: []string{"felis offsite list"},
|
|
},
|
|
} {
|
|
t.Run(tc.what, func(t *testing.T) {
|
|
key := testKey(t)
|
|
b := newMemBucket()
|
|
var names []string
|
|
for _, p := range tc.bundles {
|
|
names = append(names, putDB(t, b, key, p.daysAgo, p.counts))
|
|
}
|
|
name, m, err := ChooseDB(context.Background(), b, key)
|
|
if tc.pick >= 0 {
|
|
if err != nil || name != names[tc.pick] {
|
|
t.Fatalf("ChooseDB = %q, %v; want %s", name, err, names[tc.pick])
|
|
}
|
|
if want := tc.bundles[tc.pick].counts; (m.Counts == nil) != (want == nil) || (want != nil && *m.Counts != *want) {
|
|
t.Errorf("manifest counts = %v, want %v", m.Counts, want)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("ChooseDB picked %s, want a refusal", name)
|
|
}
|
|
msg := err.Error()
|
|
last := -1
|
|
for _, i := range tc.named {
|
|
at := strings.Index(msg, names[i])
|
|
if at < 0 {
|
|
t.Fatalf("refusal does not name %s: %s", names[i], msg)
|
|
}
|
|
if at < last {
|
|
t.Errorf("refusal names %s out of order (newest first): %s", names[i], msg)
|
|
}
|
|
last = at
|
|
}
|
|
for _, i := range tc.unnamed {
|
|
if strings.Contains(msg, names[i]) {
|
|
t.Errorf("refusal names %s: %s", names[i], msg)
|
|
}
|
|
}
|
|
for _, s := range tc.says {
|
|
if !strings.Contains(msg, s) {
|
|
t.Errorf("refusal lacks %q: %s", s, msg)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
if _, _, err := ChooseDB(context.Background(), newMemBucket(), testKey(t)); err == nil || !strings.Contains(err.Error(), "no database bundle") {
|
|
t.Errorf("empty bucket: err = %v", err)
|
|
}
|
|
|
|
// A bundle it cannot read stops the choice, named.
|
|
key := testKey(t)
|
|
b := newMemBucket()
|
|
putDB(t, b, key, 2, &dbbackup.Counts{Users: 5, Servers: 3})
|
|
other := putDB(t, b, testKey(t), 1, &dbbackup.Counts{Users: 5, Servers: 3})
|
|
newest := putDB(t, b, key, 0, &dbbackup.Counts{})
|
|
if _, _, err := ChooseDB(context.Background(), b, key); !errors.Is(err, ErrAuth) || !strings.Contains(err.Error(), other) {
|
|
t.Errorf("an older bundle under another key: err = %v, want ErrAuth naming %s", err, other)
|
|
}
|
|
if _, _, err := ChooseDB(context.Background(), b, testKey(t)); !errors.Is(err, ErrAuth) || !strings.Contains(err.Error(), newest) {
|
|
t.Errorf("the wrong key: err = %v, want ErrAuth naming %s", err, newest)
|
|
}
|
|
}
|