package main import ( "bytes" "compress/gzip" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) // A client's settings as the client writes them, with a certificate, a server and user ids that no // answer may carry. const cfg = `[General] clientVersion=34.0.4daily launchOnSystemStartup=true [Accounts] 0\Folders\1\journalPath=.sync_abc.db 0\Folders\1\localPath=/home/operator/Nextcloud/ 0\Folders\1\paused=false 0\Folders\1\targetPath=/ 0\Folders\1\virtualFilesMode=off 0\Folders\2\localPath=/home/operator/Photos/ 0\Folders\2\paused=true 0\Folders\2\targetPath=/Photos 0\General\CaCertificates="@ByteArray(-----BEGIN CERTIFICATE-----\nMIIE6zCC\n-----END CERTIFICATE-----)" 0\authType=webflow 0\dav_user=kc-1234-uid 0\displayName=The Operator 0\url=https://cloud.example.test:8443 0\user=kc-1234-uid@cloud.example.test 0\webflow_user=kc-1234-uid version=13 ` const syncLog = `/home/operator/Nextcloud/ # timestamp | duration | file | instruction | dir | modtime | etag | size | fileId | status | errorString | http result code | other size | other modtime | X-Request-ID #=#=#=# Syncrun started 2026-10-05T07:27:35Z ||old.md|2|2|1|e|1|i|13||0|1|1|| #=#=#=# Syncrun finished 2026-10-05T07:27:35Z (last step: 16 msec, total: 170 msec) #=#=#=# Syncrun started 2026-10-05T09:27:40Z #=#=#=#=# Propagation starts 2026-10-05T09:27:40Z (last step: 131 msec, total: 131 msec) 13:58:53||Notes/a.md|8|1|1|e|1|i|13||201|0|0|r| 13:58:54||Notes/b.md|8|1|1|e|1|i|13|"/Notes/b.md" is locked|423|0|0|r| 13:58:55||kc-1234-uid/c.md|8|1|1|e|1|i|13|File has changed since discovery|200|0|0|r| #=#=#=# Syncrun finished 2026-10-05T09:27:40Z (last step: 17 msec, total: 148 msec) ` func clientLine(level, text string) string { return "2026-10-05 11:27:41:151 [ " + level + " nextcloud.gui.folder /src/folder.cpp:1 ]:\t" + text } func newClient(t *testing.T) *fake { f := newFake(t) f.write(testHome+"/"+configFile, cfg) f.write(testHome+"/Nextcloud/.sync_abc.db", "") f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", syncLog) // An older log of the same folder, in the old place: the newer one wins. f.write(testHome+"/.config/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2023-11-17T00:00:00Z\n") old := filepath.Join(f.Root, testHome, ".config/Nextcloud/Nextcloud_sync.log") _ = os.Chtimes(old, time.Unix(1_700_000_000, 0), time.Unix(1_700_000_000, 0)) var gz bytes.Buffer z := gzip.NewWriter(&gz) _, _ = z.Write([]byte(clientLine("info", "older") + "\n" + clientLine("warning", "Network error on https://cloud.example.test:8443/x Authorization: Bearer abc.def") + "\n")) _ = z.Close() f.write(testHome+"/"+logDir+"/20261005_0927_nextcloud.log.0.gz", gz.String()) f.write(testHome+"/"+logDir+"/20261005_1127_permanent_delete.log.0", "not the client's log\n") f.write(testHome+"/"+logDir+"/20261005_1127_nextcloud.log.0", clientLine("info", "Sync finished for folder of account [kc-1234-uid@cloud.example.test]")+"\n"+clientLine("critical", "Could not read journal")+"\n") gzPath := filepath.Join(f.Root, testHome, logDir, "20261005_0927_nextcloud.log.0.gz") _ = os.Chtimes(gzPath, time.Now().Add(-time.Hour), time.Now().Add(-time.Hour)) f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nName=Nextcloud\nExec=\"/usr/bin/nextcloud\" --background\nX-GNOME-Autostart-enabled=true\n") f.answer = func(name string, args []string) Output { switch { case name == "pacman": return Output{Stdout: "nextcloud-client 2:34.0.4-1\n"} case name == "systemctl" && len(args) > 1 && args[1] == "is-enabled": return Output{Stdout: "disabled\n", Code: 1} } return Output{} } return f } func noSecrets(t *testing.T, v any) string { t.Helper() raw, err := json.Marshal(v) if err != nil { t.Fatal(err) } s := string(raw) for _, never := range []string{"example.test", "kc-1234", "CERTIFICATE", "abc.def", "/home/operator"} { if strings.Contains(s, never) { t.Errorf("the answer carries %q: %s", never, s) } } return s } func TestStatusNamesAccountsAndFoldersWithTheirLastSyncAndNothingSecret(t *testing.T) { f := newClient(t) f.proc(3865, 1000, clientComm, []string{"/usr/bin/nextcloud", "--background"}, "session-c1.scope") s, err := f.Status() if err != nil { t.Fatal(err) } noSecrets(t, s) if s.Installed != "2:34.0.4-1" || len(s.Running) != 1 || s.Running[0].StartedIn != "session-c1.scope" { t.Fatalf("%+v", s) } if !s.StartedBy.Starts || s.StartedBy.From != "~/.config/autostart/Nextcloud.desktop" { t.Fatalf("%+v", s.StartedBy) } if s.Config.ClientVersion != "34.0.4daily" || s.Config.LaunchAtStartup == nil || !*s.Config.LaunchAtStartup { t.Fatalf("%+v", s.Config) } if len(s.Config.Accounts) != 1 { t.Fatalf("%+v", s.Config.Accounts) } a := s.Config.Accounts[0] if a.Name != "The Operator" || a.Auth != "webflow" || len(a.Folders) != 2 { t.Fatalf("%+v", a) } main, photos := a.Folders[0], a.Folders[1] if main.Local != "~/Nextcloud/" || main.RemotePath != "/" || main.Paused || !main.JournalFound || main.VirtualFiles != "off" { t.Fatalf("%+v", main) } run := main.LastSync if run == nil || run.State != "finished" || run.Started != "2026-10-05T09:27:40Z" || run.Finished != "2026-10-05T09:27:40Z" || run.Changes != 3 || run.Errors != 2 || run.Log != "~/.local/share/Nextcloud/Nextcloud_sync.log" { t.Fatalf("%+v", run) } if run.Problems[0].File != "Notes/b.md" || run.Problems[0].HTTP != "423" || run.Problems[1].File != "/c.md" { t.Fatalf("%+v", run.Problems) } if !photos.Paused || photos.RemotePath != "/Photos" || photos.LastSync != nil { t.Fatalf("%+v", photos) } // Warnings and worse of the two newest client logs, oldest first, hidden. if len(s.Problems) != 2 || !strings.Contains(s.Problems[0], "/x Authorization: ") || !strings.Contains(s.Problems[1], "Could not read journal") { t.Fatalf("%q", s.Problems) } } func TestARunWithoutItsEndIsRunningAndALogWithoutRunsIsNever(t *testing.T) { f := newClient(t) f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2026-10-05T10:00:00Z\n||x|1|1|1|e|1|i|13||200|0|0||\n") s, _ := f.Status() if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "running" || r.Finished != "" || r.Changes != 1 { t.Fatalf("%+v", r) } f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n") s, _ = f.Status() if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "never" { t.Fatalf("%+v", r) } } func TestNoSettingsIsNoAccountNotAnError(t *testing.T) { f := newFake(t) f.answer = func(string, []string) Output { return Output{Code: 1} } s, err := f.Status() if err != nil || s.Config.Found || len(s.Config.Accounts) != 0 || s.Installed != "" { t.Fatalf("%+v %v", s, err) } } func TestTheLogIsBoundedHiddenAndReadAcrossRotations(t *testing.T) { f := newClient(t) l, err := f.Log("client", 3, false) if err != nil { t.Fatal(err) } noSecrets(t, l) if len(l.Lines) != 3 || !strings.Contains(l.Lines[0], "Authorization: ") || !strings.Contains(l.Lines[1], "[]") || len(l.Files) != 2 || !strings.HasSuffix(l.Files[0], "_1127_nextcloud.log.0") { t.Fatalf("%+v", l) } l, _ = f.Log("client", 1, false) if len(l.Lines) != 1 || len(l.Files) != 1 || !strings.Contains(l.Lines[0], "Could not read journal") { t.Fatalf("%+v", l) } l, _ = f.Log("client", 50, true) if len(l.Lines) != 2 { t.Fatalf("%+v", l) } l, _ = f.Log("sync", 2, false) noSecrets(t, l) if len(l.Lines) != 2 || !strings.HasPrefix(l.Lines[1], "#=#=#=# Syncrun finished") || l.Files[0] != "~/.local/share/Nextcloud/Nextcloud_sync.log" { t.Fatalf("%+v", l) } if _, err := f.Log("server", 2, false); err == nil { t.Fatal("an unknown source is refused") } empty := newFake(t) if l, err := empty.Log("client", 5, false); err != nil || l.Note == "" || l.Lines == nil { t.Fatalf("%+v %v", l, err) } } func TestRestartEndsTheClientAndStartsOneInTheSession(t *testing.T) { f := newClient(t) if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") { t.Fatalf("without a desktop: %v", err) } f.desktopSession() f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-4.scope") f.onStart = func(argv []string) { f.proc(9000, 1000, clientComm, argv, "app.slice/"+restartAs+".service") } a, err := f.Restart() if err != nil { t.Fatal(err) } if len(a.Ended) != 1 || a.Ended[0] != 3865 || len(a.Running) != 1 || a.Running[0].PID != 9000 || a.Running[0].StartedIn != restartAs+".service" || a.Running[0].Command != "/usr/bin/nextcloud --background" { t.Fatalf("%+v", a) } f.onStart = nil if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u "+restartAs) { t.Fatalf("a start that shows no process: %v", err) } } func TestCheckPassesOneStartAndNamesEveryOther(t *testing.T) { f := newClient(t) f.desktopSession() f.write(testHome+"/Photos/.keep", "") f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope") c, err := f.Check() if err != nil { t.Fatal(err) } // The one finding is the paused folder. if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "~/Photos/ is paused") || len(c.Starts) != 1 { t.Fatalf("%+v", c) } noSecrets(t, c) f.write(testHome+"/.config/i3/config", "exec --no-startup-id nextcloud\n") f.answer = func(name string, args []string) Output { switch name { case "pacman": return Output{Code: 1, Stderr: "error: package 'nextcloud-client' was not found"} case "systemctl": return Output{Stdout: "enabled\n"} case "dex": return Output{Code: 127, Err: ErrNotInstalled} } return Output{} } f.proc(3866, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope") f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nExec=nextcloud\nHidden=true\n") c, _ = f.Check() all := noSecrets(t, c) for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1", "packaged user unit", "2 clients run"} { if !strings.Contains(all, want) { t.Errorf("no finding %q in %s", want, all) } } if len(c.Starts) != 2 { t.Fatalf("%q", c.Starts) } }