209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSnapshotUsesDatabaseWatermarkWhileArchiveKeepsGrowing(t *testing.T) {
|
|
for _, compression := range []string{"none", "zstd"} {
|
|
t.Run(compression, func(t *testing.T) {
|
|
account, writer, restore := setupSnapshotTest(t, compression)
|
|
defer restore()
|
|
|
|
first := RawMessage{
|
|
MessageID: "first@example.com",
|
|
Body: []byte("Message-ID: <first@example.com>\r\nSubject: First\r\n\r\nFirst body\r\nFrom quoted\r\n"),
|
|
InternalDate: time.Unix(1_700_000_000, 0),
|
|
}
|
|
firstInfo := appendSnapshotTestMessage(t, account, writer, "INBOX", first)
|
|
snapshotRoot := filepath.Join(t.TempDir(), "snapshot")
|
|
var secondInfo MboxAppendInfo
|
|
err := createSnapshot(snapshotRoot, func() error {
|
|
second := RawMessage{
|
|
MessageID: "second@example.com",
|
|
Body: []byte("Message-ID: <second@example.com>\r\nSubject: Second\r\n\r\nSecond body"),
|
|
InternalDate: time.Unix(1_700_000_100, 0),
|
|
}
|
|
secondInfo = appendSnapshotTestMessage(t, account, writer, "INBOX", second)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
manifest := readSnapshotTestManifest(t, snapshotRoot)
|
|
if runtime.GOOS != "windows" {
|
|
databaseInfo, err := os.Stat(filepath.Join(snapshotRoot, filepath.FromSlash(manifest.DatabasePath)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if permissions := databaseInfo.Mode().Perm(); permissions != 0o600 {
|
|
t.Fatalf("snapshot database permissions=%#o, want 0600", permissions)
|
|
}
|
|
}
|
|
if manifest.IndexRecords != 1 || manifest.PreciseCopied != 1 || len(manifest.Files) != 1 {
|
|
t.Fatalf("snapshot crossed DB boundary: %#v", manifest)
|
|
}
|
|
if manifest.Files[0].Bytes != firstInfo.FileOffset+firstInfo.FrameLen {
|
|
t.Fatalf("snapshot bytes=%d, want first committed boundary=%d", manifest.Files[0].Bytes, firstInfo.FileOffset+firstInfo.FrameLen)
|
|
}
|
|
sourceInfo, err := os.Stat(secondInfo.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sourceInfo.Size() <= manifest.Files[0].Bytes {
|
|
t.Fatalf("source did not grow beyond snapshot boundary: source=%d snapshot=%d", sourceInfo.Size(), manifest.Files[0].Bytes)
|
|
}
|
|
snapshotMbox := filepath.Join(snapshotRoot, filepath.FromSlash(manifest.Files[0].Path))
|
|
messages, err := readMboxMessages(snapshotMbox)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(messages) != 1 || !strings.Contains(string(messages[0]), "First body") {
|
|
t.Fatalf("snapshot messages=%d, expected only first message", len(messages))
|
|
}
|
|
if err := VerifySnapshot(snapshotRoot); err != nil {
|
|
t.Fatalf("verify valid snapshot: %v", err)
|
|
}
|
|
var liveIndex int
|
|
if err := DB.QueryRow(`SELECT count(*) FROM mbox_index WHERE account_id=?`, account.ID).Scan(&liveIndex); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if liveIndex != 2 {
|
|
t.Fatalf("snapshot verification altered live database: index=%d", liveIndex)
|
|
}
|
|
|
|
f, err := os.OpenFile(snapshotMbox, os.O_APPEND|os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.Write([]byte("tamper")); err != nil {
|
|
_ = f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := VerifySnapshot(snapshotRoot); err == nil {
|
|
t.Fatal("verification accepted a modified mbox")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSnapshotRefusesKnownMissingArchiveIdentity(t *testing.T) {
|
|
account, _, restore := setupSnapshotTest(t, "none")
|
|
defer restore()
|
|
identity := MessageIdentity{MessageID: "missing@example.com", BodySHA256: bodySHA256([]byte("missing"))}
|
|
if err := MarkIdentityCopied(account.ID, "INBOX", identity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := filepath.Join(t.TempDir(), "refused")
|
|
err := CreateSnapshot(target)
|
|
if err == nil || !strings.Contains(err.Error(), "no archive index") {
|
|
t.Fatalf("snapshot error=%v, want missing archive refusal", err)
|
|
}
|
|
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
|
t.Fatalf("refused snapshot unexpectedly finalized: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotDoesNotOverwriteExistingTarget(t *testing.T) {
|
|
_, _, restore := setupSnapshotTest(t, "none")
|
|
defer restore()
|
|
target := filepath.Join(t.TempDir(), "existing")
|
|
if err := os.Mkdir(target, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sentinel := filepath.Join(target, "keep")
|
|
if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := CreateSnapshot(target); err == nil {
|
|
t.Fatal("snapshot overwrote an existing target")
|
|
}
|
|
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep" {
|
|
t.Fatalf("existing target changed: %q %v", got, err)
|
|
}
|
|
}
|
|
|
|
func setupSnapshotTest(t *testing.T, compression string) (Account, *MboxWriter, func()) {
|
|
t.Helper()
|
|
oldCfg, oldDB := Cfg, DB
|
|
root := t.TempDir()
|
|
Cfg = Config{
|
|
DBPath: filepath.Join(root, "mail-graveyard.db"),
|
|
MboxRoot: filepath.Join(root, "backup"),
|
|
MboxCompression: compression,
|
|
}
|
|
DB = nil
|
|
if err := ConnectDB(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restore := func() {
|
|
if DB != nil {
|
|
_ = DB.Close()
|
|
}
|
|
Cfg, DB = oldCfg, oldDB
|
|
}
|
|
if err := SaveAccount(Account{
|
|
Name: "snapshot-account", SrcHost: "source.example", SrcPort: 993, SrcSecurity: "tls", SrcUser: "source@example.com", SrcPass: "x",
|
|
DstHost: "target.example", DstPort: 993, DstSecurity: "tls", DstUser: "target@example.com", DstPass: "x",
|
|
MboxDir: "snapshot@example.com", Active: true,
|
|
}); err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
account, err := GetAccount("snapshot-account")
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
writer, err := NewMboxWriter(filepath.Join(Cfg.MboxRoot, account.MboxDir))
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
return account, writer, restore
|
|
}
|
|
|
|
func appendSnapshotTestMessage(t *testing.T, account Account, writer *MboxWriter, folder string, message RawMessage) MboxAppendInfo {
|
|
t.Helper()
|
|
identity := identityForRawMessage(message)
|
|
info, err := writer.Append(folder, message)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveMboxIndex(MboxIndexEntry{
|
|
AccountID: account.ID, Folder: safeMboxName(folder), MessageID: identity.MessageID, BodySHA256: identity.BodySHA256,
|
|
FileOffset: info.FileOffset, FrameLen: info.FrameLen, InnerOffset: info.InnerOffset, InnerLen: info.InnerLen,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := UpdateMboxIndexState(account.ID, safeMboxName(folder), info.FileOffset+info.FrameLen); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MarkIdentityCopied(account.ID, safeMboxName(folder), identity); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return info
|
|
}
|
|
|
|
func readSnapshotTestManifest(t *testing.T, root string) SnapshotManifest {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(root, "snapshot-manifest.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var manifest SnapshotManifest
|
|
if err := json.Unmarshal(b, &manifest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return manifest
|
|
}
|