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: \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: \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 }