Started without a command, keeporg now shows a padlock in the system tray with a menu like midi-webhook's: a status line, the mirror folder, each database with its state, Sync Now, browser sign-in and sign-out, the web interface, the configuration, the log, Start at Login, About and Quit. The commands are unchanged. config.json now carries every client setting (device_label, request_timeout_seconds, ca_file, log_file, notifications), is written in full on first start and is reloaded by the tray when saved. A lock in the mirror directory admits one agent at a time, since the tray, `keeporg run` and a service unit could otherwise race each other. The tray uses fyne.io/systray: pure Go on Linux, BSD and Windows, cgo on macOS only. The server keeps no third-party dependencies. Desktop versions go to 0.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
3.4 KiB
Go
122 lines
3.4 KiB
Go
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestEnsureConfigWritesEverySetting(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "keeporg", "config.json")
|
|
c, err := EnsureConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.PollSeconds != 2 || c.RequestTimeoutSeconds != 60 || !c.Notifications {
|
|
t.Fatalf("defaults not applied: %+v", c)
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var keys map[string]any
|
|
if err := json.Unmarshal(b, &keys); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"server_url", "username", "token", "device_label", "mirror_dir",
|
|
"poll_seconds", "request_timeout_seconds", "ca_file", "log_file", "notifications"} {
|
|
if _, ok := keys[k]; !ok {
|
|
t.Errorf("written config lacks %q", k)
|
|
}
|
|
}
|
|
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
|
|
t.Errorf("config mode %v, want 0600", fi.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigKeepsDefaultsForMissingKeys(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
// What a 0.4 client wrote: no notifications key, which must not read as
|
|
// false.
|
|
old := `{"server_url":"https://k.example.com/","token":"t","username":"u","mirror_dir":"","poll_seconds":0}`
|
|
if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !c.Notifications || c.PollSeconds != 2 || c.MirrorDir == "" || c.DeviceLabel == "" {
|
|
t.Fatalf("defaults lost: %+v", c)
|
|
}
|
|
if c.ServerURL != "https://k.example.com" {
|
|
t.Fatalf("trailing slash kept: %q", c.ServerURL)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigExpandsHome(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
t.Skip(err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(path, []byte(`{"mirror_dir":"~/Vaults","log_file":"~/keeporg.log"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.MirrorDir != filepath.Join(home, "Vaults") || c.LogFile != filepath.Join(home, "keeporg.log") {
|
|
t.Fatalf("~ not expanded: %q %q", c.MirrorDir, c.LogFile)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigRejectsUnusableSettings(t *testing.T) {
|
|
for name, body := range map[string]string{
|
|
"scheme": `{"server_url":"keeporg.example.com"}`,
|
|
"ca_file": `{"ca_file":"/nonexistent/ca.pem"}`,
|
|
"syntax": `{"server_url":`,
|
|
} {
|
|
path := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadConfig(path); err == nil || errors.Is(err, ErrNoConfig) {
|
|
t.Errorf("%s: want a validation error, got %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigAPIRejectsEmptyCAFile(t *testing.T) {
|
|
ca := filepath.Join(t.TempDir(), "ca.pem")
|
|
if err := os.WriteFile(ca, []byte("not a certificate"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := DefaultConfig()
|
|
c.ServerURL, c.CAFile = "https://k.example.com", ca
|
|
if _, err := c.API(); err == nil || !strings.Contains(err.Error(), "no PEM") {
|
|
t.Fatalf("want a PEM error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLockDirAdmitsOneAgent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
unlock, err := LockDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LockDir(dir); !errors.Is(err, ErrMirrorBusy) {
|
|
t.Fatalf("second lock: want ErrMirrorBusy, got %v", err)
|
|
}
|
|
unlock()
|
|
again, err := LockDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("lock after release: %v", err)
|
|
}
|
|
again()
|
|
}
|