+package main
+
+import (
+ "archive/tar"
+ "context"
+ "database/sql"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRunningDaemonBackupRestoresTheChatAndLinks(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ files := map[string]string{
+ "auth.json": `{"access_token":"fake-secret"}`,
+ "telegram.json": `{"token":"fake-token","owner":123}`,
+ "config.json": `{"endpoint":"api"}`,
+ "agent/instructions.md": "Keep the instructions.\n",
+ "agent/telegram-offset.json": "23\n",
+ }
+ for name, data := range files {
+ path := filepath.Join(dir, name)
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
+ require.NoError(t, os.WriteFile(path, []byte(data), 0o600))
+ }
+
+ memory, whatsapp, subagents := backupStartDaemon(t, dir)
+ require.True(t, whatsapp.isLinked())
+
+ _, err := memory.Append("note", "A durable decision.")
+ require.NoError(t, err)
+
+ _, err = memory.Append("note", "The next decision.")
+ require.NoError(t, err)
+ require.NoError(t, memory.Settle(t.Context()))
+
+ child, err := subagents.Create(t.Context())
+ require.NoError(t, err)
+
+ _, err = child.Append("note", "subagent")
+ require.NoError(t, err)
+
+ _, err = child.Append("user", "A saved task.")
+ require.NoError(t, err)
+
+ childText, err := child.Show()
+ require.NoError(t, err)
+
+ for _, excluded := range []string{"probe/skip", "agent-backup-example/skip"} {
+ path := filepath.Join(dir, excluded)
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
+ require.NoError(t, os.WriteFile(path, []byte("excluded"), 0o600))
+ }
+
+ archive := filepath.Join(t.TempDir(), "daily.tar.gz")
+ t.Setenv("ONECHAT_DIR", dir)
+
+ var answer strings.Builder
+
+ require.NoError(t, backup(t.Context(), archive, &answer))
+ require.Regexp(t, `^`+archive+` \([0-9]+ bytes\)\n$`, answer.String())
+
+ info, err := os.Stat(archive)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+
+ restored := t.TempDir()
+ require.NoError(t, backupRestore(t.Context(), archive, restored))
+
+ for name, data := range files {
+ bytes, err := os.ReadFile(filepath.Join(restored, name))
+ require.NoError(t, err)
+ require.Equal(t, data, string(bytes))
+ }
+
+ for _, excluded := range []string{"probe", "agent-backup-example", socketFileName, "agent/lock"} {
+ _, err = os.Stat(filepath.Join(restored, excluded))
+ require.ErrorIs(t, err, os.ErrNotExist)
+ }
+
+ restoredMemory, restoredWhatsApp, restoredSubagents := backupStartDaemon(t, restored)
+ require.Equal(t, memory.Len(), restoredMemory.Len())
+ require.Equal(t, memory.View(), restoredMemory.View())
+
+ for id := range memory.Len() {
+ expected, err := memory.Message(id)
+ require.NoError(t, err)
+
+ actual, err := restoredMemory.Message(id)
+ require.NoError(t, err)
+ require.Equal(t, expected, actual)
+ }
+
+ require.True(t, restoredWhatsApp.isLinked())
+ require.Equal(t, whatsapp.link().own, restoredWhatsApp.link().own)
+
+ restoredChild, err := restoredSubagents.Get(t.Context(), child.name)
+ require.NoError(t, err)
+
+ restoredText, err := restoredChild.Show()
+ require.NoError(t, err)
+ require.Equal(t, childText, restoredText)
+}
+
+func TestRestoreRefusesNonemptyDataWithoutChangingIt(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "keep")
+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
+
+ err := backupRestore(t.Context(), "missing.tar.gz", dir)
+ require.ErrorContains(t, err, "empty data directory")
+
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ require.Equal(t, "keep", string(data))
+}
+
+func TestRestoreRefusesTheDaemonTokenLock(t *testing.T) {
+ dir := t.TempDir()
+ tokens, err := lockTokens(dir)
+ require.NoError(t, err)
+
+ defer func() {
+ require.NoError(t, tokens.Close())
+ }()
+
+ err = backupRestore(t.Context(), "missing.tar.gz", dir)
+ require.ErrorIs(t, err, errTokensInUse)
+}
+
+func TestUnsafeRestoreInstallsNoArchiveFiles(t *testing.T) {
+ archive := backupTestArchive(t,
+ tar.Header{
+ Name: "safe",
+ Typeflag: tar.TypeReg,
+ Mode: 0o600,
+ },
+ tar.Header{
+ Name: "../escape",
+ Typeflag: tar.TypeReg,
+ Mode: 0o600,
+ },
+ )
+ dir := t.TempDir()
+ err := backupRestore(t.Context(), archive, dir)
+ require.ErrorContains(t, err, "unsafe archive path")
+
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1)
+ require.Equal(t, tokenLockFileName, entries[0].Name())
+}
+
+func TestBackupNeedsARunningDaemon(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", t.TempDir())
+
+ err := backup(t.Context(), filepath.Join(t.TempDir(), "backup.tar.gz"), io.Discard)
+ require.EqualError(t, err, "backup needs a running daemon")
+}
+
+func TestWhatsAppSessionBackupIncludesCommittedWALWrites(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
+ address, err := whatsappSessionAddress(filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
+ require.NoError(t, err)
+
+ database, err := sql.Open("sqlite", address)
+ require.NoError(t, err)
+
+ defer func() {
+ require.NoError(t, database.Close())
+ }()
+
+ _, err = database.ExecContext(t.Context(), "PRAGMA journal_mode=WAL; CREATE TABLE backup_sample (value TEXT); INSERT INTO backup_sample VALUES ('saved');")
+ require.NoError(t, err)
+
+ target := filepath.Join(t.TempDir(), "copy", whatsappSessionFileName)
+ require.NoError(t, whatsapp.BackupSession(t.Context(), target))
+
+ address, err = whatsappSessionAddress(target)
+ require.NoError(t, err)
+
+ restored, err := sql.Open("sqlite", address)
+ require.NoError(t, err)
+
+ defer func() {
+ require.NoError(t, restored.Close())
+ }()
+
+ var value string
+
+ err = restored.QueryRowContext(t.Context(), "SELECT value FROM backup_sample").Scan(&value)
+ require.NoError(t, err)
+ require.Equal(t, "saved", value)
+
+ info, err := os.Stat(target)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+}
+
+func TestSessionBackupWaitsForRemovalToFinish(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+
+ whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
+ disconnect := &BackupDisconnect{
+ WhatsAppClient: fake,
+ entered: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+
+ var release sync.Once
+
+ defer release.Do(func() {
+ close(disconnect.release)
+ })
+
+ whatsapp.mutex.Lock()
+
+ whatsapp.client = disconnect
+ whatsapp.mutex.Unlock()
+
+ removed := make(chan error, 1)
+ go func() {
+ removed <- whatsapp.removeSession()
+ }()
+
+ <-disconnect.entered
+
+ target := filepath.Join(t.TempDir(), "copy", whatsappSessionFileName)
+ backedUp := make(chan error, 1)
+ started := make(chan struct{})
+ go func() {
+ close(started)
+
+ backedUp <- whatsapp.BackupSession(t.Context(), target)
+ }()
+
+ <-started
+ require.Never(t, func() bool {
+ return len(backedUp) != 0
+ }, 50*time.Millisecond, time.Millisecond)
+ release.Do(func() {
+ close(disconnect.release)
+ })
+ require.NoError(t, <-removed)
+ require.NoError(t, <-backedUp)
+
+ for _, path := range []string{whatsapp.sessionPath(), target} {
+ _, err := os.Stat(path)
+ require.ErrorIs(t, err, os.ErrNotExist)
+ }
+}
+
+// BackupDisconnect holds session removal in its unlocked client cleanup.
+type BackupDisconnect struct {
+ WhatsAppClient
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (d *BackupDisconnect) Disconnect() {
+ close(d.entered)
+ <-d.release
+}
+
+// backupStartDaemon serves fake model and WhatsApp clients on the socket of
+// dir. It keeps all state files under dir and holds the daemon token lock.
+func backupStartDaemon(t *testing.T, dir string) (*Memory, *WhatsApp, *Subagents) {
+ t.Helper()
+
+ tokens, err := lockTokens(dir)
+ require.NoError(t, err)
+
+ clock := newTestClock()
+ agent, err := openAgent(filepath.Join(dir, daemonAgentDirName))
+ require.NoError(t, err)
+
+ memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer(), discardLog())
+ require.NoError(t, err)
+
+ whatsapp, _, _ := whatsappStartTest(t, dir, clock)
+ subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
+ schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), clock.Now, discardLog())
+ require.NoError(t, err)
+
+ server := newModelFake(t)
+ client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
+ listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan error, 1)
+ go func() {
+ done <- daemonServe(ctx, listener, nil, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) {
+ return newTurnLoop(agent, memory, subagents, whatsapp, schedules, client, clock.Now, discardLog(), notify)
+ })
+ }()
+
+ t.Cleanup(func() {
+ cancel()
+ require.NoError(t, <-done)
+ require.NoError(t, subagents.Close())
+ require.NoError(t, memory.Close())
+ require.NoError(t, tokens.Close())
+ })
+
+ return memory, whatsapp, subagents
+}