Both are official packages already on both workstations, started once by dex from an XDG autostart entry (the client's own, the package's). The modules declare the package, add no second start, own none of the apps' files, and give the mesh status, log, restart and check.
270 lines
10 KiB
Go
270 lines
10 KiB
Go
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 != "<account>/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], "<server>/x Authorization: <hidden>") ||
|
|
!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: <hidden>") || !strings.Contains(l.Lines[1], "[<account>]") ||
|
|
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)
|
|
}
|
|
}
|