From 8f5b6c0727a70bdd9859fb70b6174de42f9544c5 Mon Sep 17 00:00:00 2001 From: wgroeneveld Date: Tue, 7 Mar 2023 13:50:04 +0100 Subject: [PATCH] added backup wrapper exec.Command tests --- .gitignore | 1 + TODO.md | 4 +- restic/config.go | 11 +-- restic/config_test.go | 46 ++++++++++++ restic/wrapper.go | 7 +- restic/wrapper_test.go | 163 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 restic/config_test.go create mode 100644 restic/wrapper_test.go diff --git a/.gitignore b/.gitignore index d8fd397..784bde5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/ on_exit_*.txt restictray +config.json restictray.app/ .DS_Store diff --git a/TODO.md b/TODO.md index 014834a..3d62acd 100644 --- a/TODO.md +++ b/TODO.md @@ -13,4 +13,6 @@ - [ ] `restic backup` can result in `Warning: at least one source file could not be read\n: exit status 3`. This returns an error, but the backup itself seems to be all right. - [X] exit code 3: https://restic.readthedocs.io/en/stable/040_backup.html likely to be a permission problem, perhaps ignoring instead of panicking as of now? - [ ] `restic list locks` showed a dangling one; PID of already gone `restic` process. Why? Unable to reproduce? - - [ ] Verify backups with `restic check`? If not okay, remove (snapshot/folder?) and rebackup? What's the plan then? \ No newline at end of file + - [ ] Verify backups with `restic check`? If not okay, remove (snapshot/folder?) and rebackup? What's the plan then? +- [ ] Implement `restic init`, reducing the need for an existing backup +- [ ] Cross-platform compatibility with Linux? Shouldn't be hard. \ No newline at end of file diff --git a/restic/config.go b/restic/config.go index ada8d7a..2f6ebc7 100644 --- a/restic/config.go +++ b/restic/config.go @@ -22,29 +22,30 @@ const ( var home, _ = os.UserHomeDir() var executable, _ = os.Executable() var isDev = os.Getenv("RESTICTRAY_DEV") +var configDir = home + "/.resitc/" func IsDev() bool { return isDev != "" } func PasswordFile() string { - return home + "/.restic/password.txt" + return configDir + "password.txt" } func LogFile() string { - return home + "/.restic/log.txt" + return configDir + "log.txt" } func ExcludeFile() string { - return home + "/.restic/excludes.txt" + return configDir + "excludes.txt" } func ConfigFile() string { - return home + "/.restic/config.json" + return configDir + "config.json" } func (cnf *Config) MountDir() string { - return home + "/.restic/mnt" + return configDir + "mnt" } func (cnf *Config) CreateMountDirIfDoesntExist() error { diff --git a/restic/config_test.go b/restic/config_test.go new file mode 100644 index 0000000..2408ece --- /dev/null +++ b/restic/config_test.go @@ -0,0 +1,46 @@ +package restic + +import ( + "github.com/stretchr/testify/assert" + "io/fs" + "os" + "testing" +) + +func init() { + configDir = "./" + os.RemoveAll("config.json") +} + +func TestReadConfig_NoFile_Error(t *testing.T) { + config, err := ReadConfig() + assert.Nilf(t, config, "expected config to be nil") + assert.Error(t, err) +} + +func TestReadConfig_MalformedJSON_Error(t *testing.T) { + os.WriteFile("config.json", []byte("{ whoops"), fs.ModePerm) + config, err := ReadConfig() + assert.Nilf(t, config, "expected config to be nil") + assert.ErrorContainsf(t, err, "malformed", "expected err to contain malformed msg") +} + +func TestReadConfig_ValidJSONFile_Ok(t *testing.T) { + os.WriteFile("config.json", []byte("{\"backupTimeInHours\": 5, \"repository\": \"repodir\", \"backup\": \"backupfiles\"}"), fs.ModePerm) + config, err := ReadConfig() + + assert.NoError(t, err) + assert.Equal(t, 5, config.BackupTimeInHours) + assert.Equal(t, "repodir", config.Repository) + assert.Equal(t, "backupfiles", config.Backup) +} + +func TestReadConfig_WithoutBackupTime_DefaultsTo24hrs(t *testing.T) { + os.WriteFile("config.json", []byte("{\"repository\": \"repodir\", \"backup\": \"backupfiles\"}"), fs.ModePerm) + config, err := ReadConfig() + + assert.NoError(t, err) + assert.Equal(t, 24, config.BackupTimeInHours) + assert.Equal(t, "repodir", config.Repository) + assert.Equal(t, "backupfiles", config.Backup) +} diff --git a/restic/wrapper.go b/restic/wrapper.go index 5a81145..920291b 100644 --- a/restic/wrapper.go +++ b/restic/wrapper.go @@ -53,7 +53,7 @@ func (w *Wrapper) Cleanup() { } } -func resticCmd(args ...string) *exec.Cmd { +func resticCmdFn(args ...string) *exec.Cmd { // Running from GoLand: could be /private/var/folders/5s/csgpcjlx1wg9659_485vqz880000gn/T/GoLand/restictray // Installed: could be /Applications/restictray/restictray.app/Contents/MacOS/restictray // This isn't ideal but I don't want to fiddle with build flags @@ -67,6 +67,9 @@ func resticCmd(args ...string) *exec.Cmd { return cmd } +var resticCmd = resticCmdFn +var cmdTimeout = 10 * time.Second + /* UpdateLatestSnapshots updates LatestSnapshots or returns an error. Expected restic snapshot JSON format: @@ -165,7 +168,7 @@ func (w *Wrapper) Backup(c *Config) error { select { case <-busy: busy = nil // It's alive! continue with cmd.Wait() - case <-time.After(10 * time.Second): + case <-time.After(cmdTimeout): killErr := cmd.Process.Kill() return fmt.Errorf("backup output timeout, repository server down?: %w", killErr) } diff --git a/restic/wrapper_test.go b/restic/wrapper_test.go new file mode 100644 index 0000000..a12644c --- /dev/null +++ b/restic/wrapper_test.go @@ -0,0 +1,163 @@ +package restic + +import ( + "bytes" + "fmt" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "os" + "os/exec" + "testing" + "time" +) + +func TestWrapper_LastSnapshot(t *testing.T) { + shot1 := ResticSnapshot{ + Id: "shot1", + Time: time.Date(2020, 10, 10, 10, 10, 10, 10, time.UTC), + } + shot2 := ResticSnapshot{ + Id: "shot2", + Time: time.Date(2022, 11, 11, 11, 11, 11, 11, time.UTC), + } + + cases := []struct { + label string + snapshots []ResticSnapshot + expected ResticSnapshot + }{ + { + "None yet", + []ResticSnapshot{}, + ResticSnapshot{ + Id: "(no snapshots yet)", + Time: time.Time{}, + }, + }, + { + "take the last from the array", + []ResticSnapshot{shot1, shot2}, + shot2, + }, + } + + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + wrapper := Wrapper{ + LatestSnapshots: tc.snapshots, + } + last := wrapper.LastSnapshot() + + assert.Equal(t, tc.expected.Id, last.Id) + assert.Equal(t, tc.expected.Time.Hour(), last.Time.Hour()) + assert.Equal(t, tc.expected.Time.Minute(), last.Time.Minute()) + }) + } +} + +// a very clever way to test exec.Command in go: https://jamiethompson.me/posts/Unit-Testing-Exec-Command-In-Golang/ +func fakeExecCommand(whichTest string) func(args ...string) *exec.Cmd { + return func(args ...string) *exec.Cmd { + cs := []string{"-test.run=" + whichTest, "--", "restic"} + cs = append(cs, args...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{"GO_TEST_PROCESS=1"} + return cmd + } +} + +func TestCmdSuccessWithinTime(t *testing.T) { + if os.Getenv("GO_TEST_PROCESS") != "1" { + return + } + fmt.Fprintf(os.Stdout, "some output\n") + os.Exit(0) +} +func TestCmdExitCode1WithinTime(t *testing.T) { + if os.Getenv("GO_TEST_PROCESS") != "1" { + return + } + fmt.Fprintf(os.Stdout, "some output\n") + os.Exit(1) +} +func TestCmdExitCode3WithinTime(t *testing.T) { + if os.Getenv("GO_TEST_PROCESS") != "1" { + return + } + fmt.Fprintf(os.Stdout, "some output\n") + os.Exit(3) +} + +func TestCmdTakesLongerThan2sButOutputsFaster(t *testing.T) { + if os.Getenv("GO_TEST_PROCESS") != "1" { + return + } + // don't forget \n as the scanner splits on ScanLines + time.Sleep(1 * time.Second) + fmt.Fprintf(os.Stdout, "some output after 1s\n") + time.Sleep(2 * time.Second) + fmt.Fprintf(os.Stdout, "some output after 3s\n") + os.Exit(0) +} + +func TestCmdTakesLongerThan2sToOutputAnything(t *testing.T) { + if os.Getenv("GO_TEST_PROCESS") != "1" { + return + } + time.Sleep(3 * time.Second) + fmt.Fprintf(os.Stdout, "some output, too late!\n") + os.Exit(0) +} + +func TestBackup_ExitCode1_ReturnsError(t *testing.T) { + resticCmd = fakeExecCommand("TestCmdExitCode1WithinTime") + + wrapper := Wrapper{} + err := wrapper.Backup(&Config{}) + + assert.Error(t, err) +} + +func TestBackup_ExitCode3_IssuesWarning(t *testing.T) { + resticCmd = fakeExecCommand("TestCmdExitCode3WithinTime") + var logBuffer bytes.Buffer + log.Logger = log.Output(&logBuffer) + + wrapper := Wrapper{} + err := wrapper.Backup(&Config{}) + + assert.NoError(t, err) + assert.Contains(t, logBuffer.String(), "permission problem?") +} + +func TestBackup_OutputsAndFinishesAsExpected(t *testing.T) { + resticCmd = fakeExecCommand("TestCmdSuccessWithinTime") + var logBuffer bytes.Buffer + log.Logger = log.Output(&logBuffer) + + wrapper := Wrapper{} + err := wrapper.Backup(&Config{}) + + assert.NoError(t, err) + assert.Contains(t, logBuffer.String(), "some output") +} + +func TestBackup_OutputsBefore2sButStillTakesLonger(t *testing.T) { + resticCmd = fakeExecCommand("TestCmdTakesLongerThan2sButOutputsFaster") + cmdTimeout = 2 * time.Second + + wrapper := Wrapper{} + err := wrapper.Backup(&Config{}) + + assert.NoError(t, err) +} + +func TestBackup_TakesLongerThanNeededToOutput_TimesOut(t *testing.T) { + resticCmd = fakeExecCommand("TestCmdTakesLongerThan2sToOutputAnything") + cmdTimeout = 2 * time.Second + + wrapper := Wrapper{} + err := wrapper.Backup(&Config{}) + + assert.ErrorContainsf(t, err, "backup output timeout", "timeout expected") +}