433 lines
14 KiB
Go
433 lines
14 KiB
Go
package davsync
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/backup"
|
|
"github.com/yetone/magpie/internal/library"
|
|
"github.com/yetone/magpie/internal/profile"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
"github.com/yetone/magpie/internal/settings"
|
|
)
|
|
|
|
// fakeDAV is a WebDAV server with one user, keeping files in memory.
|
|
type fakeDAV struct {
|
|
mu sync.Mutex
|
|
files map[string][]byte
|
|
etags map[string]string
|
|
dirs map[string]bool
|
|
n int
|
|
puts int
|
|
}
|
|
|
|
func (f *fakeDAV) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if u, p, ok := r.BasicAuth(); !ok || u != "me" || p != "pw" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
b, ok := f.files[r.URL.Path]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("ETag", f.etags[r.URL.Path])
|
|
w.Write(b)
|
|
case http.MethodPut:
|
|
if !f.dirs[filepath.Dir(r.URL.Path)] {
|
|
w.WriteHeader(http.StatusConflict)
|
|
return
|
|
}
|
|
if m := r.Header.Get("If-Match"); m != "" && m != f.etags[r.URL.Path] {
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
return
|
|
}
|
|
b, _ := io.ReadAll(r.Body)
|
|
f.n++
|
|
f.puts++
|
|
f.files[r.URL.Path], f.etags[r.URL.Path] = b, fmt.Sprintf(`"%d"`, f.n)
|
|
w.WriteHeader(http.StatusCreated)
|
|
case "MKCOL":
|
|
p := strings.TrimSuffix(r.URL.Path, "/")
|
|
if f.dirs[p] {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
f.dirs[p] = true
|
|
w.WriteHeader(http.StatusCreated)
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// computer is one machine's magpie: its own home, which the test moves
|
|
// between.
|
|
type computer string
|
|
|
|
func newComputer(t *testing.T) computer { return computer(t.TempDir()) }
|
|
|
|
func (c computer) use(t *testing.T) {
|
|
t.Setenv("HOME", string(c))
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(string(c), ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(string(c), ".cache"))
|
|
t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(string(c), ".claude"))
|
|
t.Setenv("CODEX_HOME", filepath.Join(string(c), ".codex"))
|
|
t.Setenv("PATH", "")
|
|
for _, v := range []string{"DSH_HOME", "PI_CODING_AGENT_DIR", "COPILOT_HOME", "CLINE_DIR", "GROK_HOME", "HERMES_HOME", "APPDATA", "LOCALAPPDATA"} {
|
|
t.Setenv(v, "")
|
|
}
|
|
}
|
|
|
|
func ids() []string {
|
|
var out []string
|
|
for _, p := range provider.Stored() {
|
|
out = append(out, p.ID+"="+p.Key)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func TestSync(t *testing.T) {
|
|
fake := &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}
|
|
srv := httptest.NewServer(fake)
|
|
defer srv.Close()
|
|
ctx := context.Background()
|
|
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Agents: true}
|
|
now := func(t *testing.T) View {
|
|
t.Helper()
|
|
if err := Now(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Status()
|
|
}
|
|
|
|
a, b := newComputer(t), newComputer(t)
|
|
a.use(t)
|
|
provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k1"})
|
|
settings.Save(settings.Settings{Theme: "dark", Proxy: "http://127.0.0.1:7890", Window: []int{900, 700}})
|
|
profile.Save("work", profile.Profile{Fields: map[string]string{"claude.model": "x"}})
|
|
same := cfg
|
|
same.Passphrase = cfg.Password
|
|
if err := Configure(same); err == nil || !strings.Contains(err.Error(), "of its own") {
|
|
t.Fatalf("a passphrase that is the password: %v", err)
|
|
}
|
|
if err := Configure(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi, _ := os.Stat(path("sync.json")); fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("sync.json is %v", fi.Mode())
|
|
}
|
|
// the first: its setup goes up, sealed
|
|
if v := now(t); v.Error != "" || v.Last.IsZero() || v.Notice != nil {
|
|
t.Fatalf("first: %+v", v)
|
|
}
|
|
up := fake.files["/dav/magpie/magpie.magpie-backup"]
|
|
if len(up) == 0 || strings.Contains(string(up), "deepseek") || strings.Contains(string(up), `"providers"`) {
|
|
t.Fatalf("on the server: %q", up)
|
|
}
|
|
// nothing changed: nothing written
|
|
now(t)
|
|
if fake.puts != 1 {
|
|
t.Fatalf("%d puts", fake.puts)
|
|
}
|
|
|
|
// b joins: a's setup comes in, b's own proxy and window stay, and what
|
|
// b had is kept aside
|
|
b.use(t)
|
|
provider.Save(provider.Provider{ID: "mine", Name: "Mine", Chat: "https://x/v1", Key: "kb"})
|
|
settings.Save(settings.Settings{Proxy: "direct", Window: []int{1, 2}})
|
|
Configure(Config{URL: cfg.URL, User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Agents: true})
|
|
v := now(t)
|
|
if v.Notice == nil || !slices.Contains(v.Notice.Here, "providers") || !slices.Contains(v.Notice.Here, "settings") {
|
|
t.Fatalf("joined: %+v", v)
|
|
}
|
|
if got := ids(); !slices.Equal(got, []string{"deepseek=k1"}) {
|
|
t.Fatalf("b's providers: %v", got)
|
|
}
|
|
if s := settings.Load(); s.Theme != "dark" || s.Proxy != "direct" || !slices.Equal(s.Window, []int{1, 2}) {
|
|
t.Fatalf("b's settings: %+v", s)
|
|
}
|
|
if ps, _ := profile.Load(); len(ps) != 1 {
|
|
t.Fatalf("b's profiles: %v", ps)
|
|
}
|
|
kept, _ := filepath.Glob(filepath.Join(settings.Dir(), "sync", "*-this-computer"+backup.Ext))
|
|
if len(kept) != 1 {
|
|
t.Fatalf("kept: %v", kept)
|
|
}
|
|
if old, err := backup.Open(must(os.ReadFile(kept[0])), "correct horse"); err != nil || old.Providers[0].ID != "mine" {
|
|
t.Fatalf("kept copy: %v %+v", err, old.Providers)
|
|
}
|
|
Dismiss()
|
|
puts := fake.puts
|
|
now(t)
|
|
if fake.puts != puts || Status().Notice != nil {
|
|
t.Fatalf("b pushed back what it brought in (%d puts)", fake.puts-puts)
|
|
}
|
|
|
|
// a adds one, b removes one: each reaches the other
|
|
a.use(t)
|
|
provider.Save(provider.Provider{ID: "kimi", Name: "Kimi", Chat: "https://api.moonshot.cn/v1", Key: "k2"})
|
|
now(t)
|
|
b.use(t)
|
|
now(t)
|
|
if got := ids(); !slices.Equal(got, []string{"deepseek=k1", "kimi=k2"}) {
|
|
t.Fatalf("b after a added: %v", got)
|
|
}
|
|
provider.Delete("deepseek")
|
|
profile.Delete("work")
|
|
now(t)
|
|
a.use(t)
|
|
if v := now(t); v.Notice != nil {
|
|
t.Fatalf("a: %+v", v.Notice)
|
|
}
|
|
if got := ids(); !slices.Equal(got, []string{"kimi=k2"}) {
|
|
t.Fatalf("a after b removed: %v", got)
|
|
}
|
|
if ps, _ := profile.Load(); len(ps) != 0 {
|
|
t.Fatalf("a's profiles: %v", ps)
|
|
}
|
|
if s := settings.Load(); s.Proxy != "http://127.0.0.1:7890" {
|
|
t.Fatalf("a's proxy: %q", s.Proxy)
|
|
}
|
|
|
|
// both change the settings: the older change gives way, and is kept
|
|
settings.Save(settings.Settings{Theme: "light", Proxy: "http://127.0.0.1:7890"})
|
|
now(t) // a's is on the server now
|
|
b.use(t)
|
|
settings.Save(settings.Settings{Theme: "system", Lang: "zh", Proxy: "direct"})
|
|
old := time.Now().Add(-time.Hour)
|
|
os.Chtimes(settings.Path(), old, old)
|
|
v = now(t)
|
|
if v.Notice == nil || !slices.Equal(v.Notice.Here, []string{"settings"}) || len(v.Notice.There) != 0 {
|
|
t.Fatalf("older here: %+v", v.Notice)
|
|
}
|
|
if s := settings.Load(); s.Theme != "light" || s.Lang == "zh" {
|
|
t.Fatalf("b's settings: %+v", s)
|
|
}
|
|
// …and the newer one stays, the server's kept aside
|
|
a.use(t)
|
|
settings.Save(settings.Settings{Theme: "dark", Proxy: "http://127.0.0.1:7890"})
|
|
os.Chtimes(settings.Path(), old, old)
|
|
now(t)
|
|
b.use(t)
|
|
Dismiss()
|
|
settings.Save(settings.Settings{Theme: "light", Lang: "en", Proxy: "direct"})
|
|
v = now(t)
|
|
if v.Notice == nil || !slices.Equal(v.Notice.There, []string{"settings"}) {
|
|
t.Fatalf("newer here: %+v", v.Notice)
|
|
}
|
|
a.use(t)
|
|
now(t)
|
|
if s := settings.Load(); s.Lang != "en" {
|
|
t.Fatalf("a didn't get b's newer settings: %+v", s)
|
|
}
|
|
|
|
// a computer that sends no keys leaves the server's where they are
|
|
c := newComputer(t)
|
|
c.use(t)
|
|
Configure(Config{URL: cfg.URL, User: "me", Password: "pw", Passphrase: "correct horse", Keys: false, Agents: true})
|
|
now(t)
|
|
provider.Save(provider.Provider{ID: "kimi", Name: "Kimi 2", Chat: "https://api.moonshot.cn/v1", Key: "k2"})
|
|
now(t)
|
|
a.use(t)
|
|
now(t)
|
|
if ps := provider.Stored(); len(ps) != 1 || ps[0].Name != "Kimi 2" || ps[0].Key != "k2" {
|
|
t.Fatalf("a after c renamed: %+v", ps)
|
|
}
|
|
remote, _ := backup.Open(fake.files["/dav/magpie/magpie.magpie-backup"], "correct horse")
|
|
if remote.Providers[0].Key != "k2" {
|
|
t.Fatalf("the server lost the key: %+v", remote.Providers)
|
|
}
|
|
|
|
// the wrong passphrase, the wrong password
|
|
d := newComputer(t)
|
|
d.use(t)
|
|
Configure(Config{URL: cfg.URL, User: "me", Password: "pw", Passphrase: "other"})
|
|
if err := Now(ctx); err == nil || !strings.Contains(err.Error(), "passphrase") || Status().Error == "" {
|
|
t.Fatalf("wrong passphrase: %v", err)
|
|
}
|
|
Configure(Config{URL: cfg.URL, User: "me", Password: "nope", Passphrase: "correct horse"})
|
|
if err := Now(ctx); err == nil || !strings.Contains(err.Error(), "password") {
|
|
t.Fatalf("wrong password: %v", err)
|
|
}
|
|
// a password or passphrase left empty is kept
|
|
Configure(Config{URL: cfg.URL, User: "me"})
|
|
if c, _ := Load(); c.Password != "nope" || c.Passphrase != "correct horse" {
|
|
t.Fatalf("kept: %+v", c)
|
|
}
|
|
if err := Off(); err != nil || Status().On {
|
|
t.Fatal("still on")
|
|
}
|
|
}
|
|
|
|
// The library goes as a part of its own: a's instructions, servers and
|
|
// skills reach b and are written into b's agents, b pushes nothing back,
|
|
// a change on either side reaches the other, and a computer that leaves
|
|
// the library out neither sends nor takes it.
|
|
func TestSyncLibrary(t *testing.T) {
|
|
fake := &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}
|
|
srv := httptest.NewServer(fake)
|
|
defer srv.Close()
|
|
ctx := context.Background()
|
|
// a setup from before the library could be left out: it goes
|
|
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Agents: true}
|
|
now := func(t *testing.T) View {
|
|
t.Helper()
|
|
if err := Now(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Status()
|
|
}
|
|
shared := func() string {
|
|
iv, _ := library.ReadInstructions()
|
|
return iv.Shared
|
|
}
|
|
say := func(t *testing.T, text string) {
|
|
t.Helper()
|
|
if _, err := library.SaveInstructions(library.InstructionsChange{Shared: &text, Agents: []string{"claude"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
a, b := newComputer(t), newComputer(t)
|
|
a.use(t)
|
|
os.MkdirAll(filepath.Join(string(a), ".claude"), 0o755)
|
|
say(t, "Be brief.")
|
|
dir := filepath.Join(string(a), "src", "notes")
|
|
os.MkdirAll(dir, 0o755)
|
|
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: notes\ndescription: Notes\n---\n"), 0o644)
|
|
if _, err := library.InstallSkills(dir, []string{""}, []string{"claude"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
Configure(cfg)
|
|
if v := now(t); v.Error != "" || !v.Library {
|
|
t.Fatalf("a: %+v", v)
|
|
}
|
|
|
|
b.use(t)
|
|
os.MkdirAll(filepath.Join(string(b), ".claude"), 0o755)
|
|
Configure(cfg)
|
|
if v := now(t); v.Error != "" {
|
|
t.Fatalf("b: %+v", v)
|
|
}
|
|
if got := shared(); got != "Be brief." {
|
|
t.Fatalf("b's instructions: %q", got)
|
|
}
|
|
if s, _ := os.ReadFile(filepath.Join(string(b), ".claude", "CLAUDE.md")); !strings.Contains(string(s), "Be brief.") {
|
|
t.Fatalf("b's claude wasn't given them: %q", s)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(string(b), ".claude", "skills", "notes", "SKILL.md")); err != nil {
|
|
t.Fatalf("b's claude has no notes: %v", err)
|
|
}
|
|
puts := fake.puts
|
|
now(t)
|
|
now(t)
|
|
if fake.puts != puts {
|
|
t.Fatalf("b pushed back the library it brought in (%d puts)", fake.puts-puts)
|
|
}
|
|
|
|
// b changes it: a gets it
|
|
say(t, "Be thorough.")
|
|
now(t)
|
|
a.use(t)
|
|
now(t)
|
|
if got := shared(); got != "Be thorough." {
|
|
t.Fatalf("a after b changed: %q", got)
|
|
}
|
|
puts = fake.puts
|
|
now(t)
|
|
if fake.puts != puts {
|
|
t.Fatal("a pushed again")
|
|
}
|
|
|
|
// a computer that leaves the library out keeps its own
|
|
c := newComputer(t)
|
|
c.use(t)
|
|
off := false
|
|
lc := cfg
|
|
lc.Library = &off
|
|
Configure(lc)
|
|
say(t, "Mine alone.")
|
|
if v := now(t); v.Library || v.Error != "" {
|
|
t.Fatalf("c: %+v", v)
|
|
}
|
|
if got := shared(); got != "Mine alone." {
|
|
t.Fatalf("c's library was synced: %q", got)
|
|
}
|
|
remote, _ := backup.Open(fake.files["/dav/magpie/magpie.magpie-backup"], "correct horse")
|
|
if remote.Library == nil || remote.Library.Texts["default"] != "Be thorough." {
|
|
t.Fatalf("the server's library: %+v", remote.Library)
|
|
}
|
|
}
|
|
|
|
// Another computer syncing between this one's read and write: the write
|
|
// is refused, and it syncs again over the other's.
|
|
func TestSyncRace(t *testing.T) {
|
|
fake := &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true, "/dav/magpie": true}}
|
|
var once sync.Once
|
|
var raced []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPut && fake.files["/dav/magpie/magpie.magpie-backup"] != nil {
|
|
once.Do(func() { // someone else's write lands first
|
|
fake.mu.Lock()
|
|
fake.n++
|
|
fake.files[r.URL.Path], fake.etags[r.URL.Path] = raced, fmt.Sprintf(`"%d"`, fake.n)
|
|
fake.mu.Unlock()
|
|
})
|
|
}
|
|
fake.ServeHTTP(w, r)
|
|
}))
|
|
defer srv.Close()
|
|
cfg := Config{URL: srv.URL + "/dav", User: "me", Password: "pw", Passphrase: "p", Keys: true}
|
|
a := newComputer(t)
|
|
a.use(t)
|
|
provider.Save(provider.Provider{ID: "one", Name: "One", Chat: "https://x/v1", Key: "k"})
|
|
Configure(cfg)
|
|
if err := Now(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// what another computer would have put: one and two
|
|
other, _ := backup.Collect(true, "")
|
|
other.Providers = append(other.Providers, provider.Provider{ID: "two", Name: "Two", Chat: "https://y/v1", Key: "k"})
|
|
other.Created = time.Now().Add(-time.Minute)
|
|
raced, _ = backup.Seal(other, "p")
|
|
|
|
provider.Save(provider.Provider{ID: "three", Name: "Three", Chat: "https://z/v1", Key: "k"})
|
|
if err := Now(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// providers changed on both since: the newer, here, stays whole, and
|
|
// the other's is kept aside
|
|
if got := ids(); !slices.Equal(got, []string{"one=k", "three=k"}) {
|
|
t.Fatalf("after the race: %v", got)
|
|
}
|
|
if n := Status().Notice; n == nil || !slices.Equal(n.There, []string{"providers"}) {
|
|
t.Fatalf("notice: %+v", n)
|
|
}
|
|
remote, err := backup.Open(fake.files["/dav/magpie/magpie.magpie-backup"], "p")
|
|
if err != nil || !slices.ContainsFunc(remote.Providers, func(p provider.Provider) bool { return p.ID == "three" }) {
|
|
t.Fatalf("server: %v %+v", err, remote.Providers)
|
|
}
|
|
}
|
|
|
|
func must[T any](v T, err error) T {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return v
|
|
}
|