405 lines
13 KiB
Go
405 lines
13 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"lodge/internal/protocol"
|
|
"lodge/internal/server"
|
|
)
|
|
|
|
const adminToken = "admin"
|
|
|
|
type machine struct {
|
|
t *testing.T
|
|
config *Config
|
|
client *Client
|
|
root string
|
|
}
|
|
|
|
func git(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v: %s", args, err, out)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func newServer(t *testing.T) string {
|
|
t.Helper()
|
|
store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken}))
|
|
t.Cleanup(api.Close)
|
|
return api.URL
|
|
}
|
|
|
|
func newOrigin(t *testing.T) string {
|
|
t.Helper()
|
|
origin := t.TempDir()
|
|
git(t, origin, "init", "-b", "main")
|
|
write(t, origin, "README.md", "readme")
|
|
write(t, origin, "docs/agents/tracked.md", "in git")
|
|
git(t, origin, "add", ".")
|
|
git(t, origin, "commit", "-m", "root "+origin)
|
|
return origin
|
|
}
|
|
|
|
func newMachine(t *testing.T, origin, url, token string) *machine {
|
|
t.Helper()
|
|
cfg := &Config{URL: url, Token: token, DataDir: t.TempDir(), path: filepath.Join(t.TempDir(), "config.json")}
|
|
if err := writeJSONFile(cfg.path, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &machine{t: t, config: cfg, client: &Client{URL: url, Token: token, DataDir: cfg.DataDir}, root: clone(t, origin)}
|
|
}
|
|
|
|
func clone(t *testing.T, origin string) string {
|
|
t.Helper()
|
|
root := filepath.Join(t.TempDir(), "clone")
|
|
git(t, origin, "clone", origin, root)
|
|
resolved, _ := filepath.EvalSymlinks(root)
|
|
return resolved
|
|
}
|
|
|
|
func setup(t *testing.T) (*machine, *machine) {
|
|
t.Helper()
|
|
url, origin := newServer(t), newOrigin(t)
|
|
return newMachine(t, origin, url, adminToken), newMachine(t, origin, url, adminToken)
|
|
}
|
|
|
|
func write(t *testing.T, root, path, content string) {
|
|
t.Helper()
|
|
if err := writeFile(filepath.Join(root, path), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (m *machine) sync() Result {
|
|
m.t.Helper()
|
|
res, err := m.config.Sync(context.Background(), m.root, true)
|
|
if err != nil {
|
|
m.t.Fatal(err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (m *machine) expect(path, content string) {
|
|
m.t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(m.root, path))
|
|
if content == "" && err == nil {
|
|
m.t.Fatalf("%s should not exist", path)
|
|
}
|
|
if content != "" && string(data) != content {
|
|
m.t.Fatalf("%s = %q (%v), want %q", path, data, err, content)
|
|
}
|
|
}
|
|
|
|
func TestFilesTravelBetweenMachinesAndStayOutOfGit(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
write(t, mac.root, "docs/agents/plan.md", "v1")
|
|
write(t, mac.root, "docs/agents/tracked.md", "edited locally")
|
|
mac.sync()
|
|
|
|
if status := git(t, mac.root, "status", "--porcelain", "--", "docs/agents/plan.md", ".lodge"); status != "" {
|
|
t.Fatalf("agent files are visible to git: %s", status)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(mac.root, ".gitignore")); err == nil {
|
|
t.Fatal(".gitignore must never be created")
|
|
}
|
|
|
|
dell.sync()
|
|
dell.expect("docs/agents/plan.md", "v1")
|
|
dell.expect("docs/agents/tracked.md", "in git")
|
|
|
|
write(t, dell.root, "docs/agents/plan.md", "v2")
|
|
dell.sync()
|
|
mac.sync()
|
|
mac.expect("docs/agents/plan.md", "v2")
|
|
|
|
os.Remove(filepath.Join(dell.root, "docs/agents/plan.md"))
|
|
dell.sync()
|
|
mac.sync()
|
|
mac.expect("docs/agents/plan.md", "")
|
|
}
|
|
|
|
func TestBranchNotesFollowTheBranchAndSharedStay(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
write(t, mac.root, "docs/agents/arch.md", "shared")
|
|
write(t, mac.root, ".agent/plan.md", "main plan")
|
|
mac.sync()
|
|
|
|
git(t, mac.root, "checkout", "-b", "feature")
|
|
mac.sync()
|
|
mac.expect(".agent/plan.md", "main plan")
|
|
write(t, mac.root, ".agent/plan.md", "feature plan")
|
|
write(t, mac.root, ".agent/tried.md", "tried")
|
|
mac.sync()
|
|
|
|
git(t, mac.root, "checkout", "main")
|
|
if _, err := mac.config.Sync(context.Background(), mac.root, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mac.expect(".agent/plan.md", "main plan")
|
|
mac.expect(".agent/tried.md", "")
|
|
mac.expect("docs/agents/arch.md", "shared")
|
|
|
|
git(t, mac.root, "checkout", "feature")
|
|
mac.sync()
|
|
mac.expect(".agent/plan.md", "feature plan")
|
|
mac.expect(".agent/tried.md", "tried")
|
|
|
|
dell.sync()
|
|
dell.expect(".agent/plan.md", "main plan")
|
|
git(t, dell.root, "checkout", "-b", "feature")
|
|
dell.sync()
|
|
dell.expect(".agent/plan.md", "feature plan")
|
|
dell.expect(".agent/tried.md", "tried")
|
|
}
|
|
|
|
func TestLaterEditWinsAndLoserStaysInHistory(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
write(t, mac.root, "docs/agents/plan.md", "base")
|
|
mac.sync()
|
|
dell.sync()
|
|
|
|
write(t, mac.root, "docs/agents/plan.md", "from mac")
|
|
old := time.Now().Add(-time.Hour)
|
|
if err := os.Chtimes(filepath.Join(mac.root, "docs/agents/plan.md"), old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write(t, dell.root, "docs/agents/plan.md", "from dell")
|
|
dell.sync()
|
|
mac.sync()
|
|
mac.expect("docs/agents/plan.md", "from dell")
|
|
|
|
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "docs/agents/plan.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(revisions) != 4 {
|
|
t.Fatalf("want base, dell, losing mac and restored dell revisions, got %d", len(revisions))
|
|
}
|
|
state, err := mac.client.State(context.Background(), repoKey(t, mac), "3")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lost, err := mac.client.Blob(context.Background(), state.Spaces[protocol.SharedSpace]["docs/agents/plan.md"].Hash)
|
|
if err != nil || string(lost) != "from mac" {
|
|
t.Fatalf("losing version is not in history: %q %v", lost, err)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentEditsOfDifferentLinesMerge(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
path := "docs/agents/plan.md"
|
|
write(t, mac.root, path, "# plan\n\n## todo\n- a\n\n## notes\n- x\n")
|
|
mac.sync()
|
|
dell.sync()
|
|
|
|
write(t, mac.root, path, "# plan\n\n## todo\n- a\n- from mac\n\n## notes\n- x\n")
|
|
write(t, dell.root, path, "# plan\n\n## todo\n- a\n\n## notes\n- x\n- from dell\n")
|
|
dell.sync()
|
|
mac.sync()
|
|
dell.sync()
|
|
|
|
merged := "# plan\n\n## todo\n- a\n- from mac\n\n## notes\n- x\n- from dell\n"
|
|
mac.expect(path, merged)
|
|
dell.expect(path, merged)
|
|
state, err := mac.client.State(context.Background(), repoKey(t, mac), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := mac.client.Blob(context.Background(), state.Spaces[protocol.SharedSpace][path].Hash); string(got) != merged {
|
|
t.Fatalf("server has %q, want %q", got, merged)
|
|
}
|
|
for _, m := range []*machine{mac, dell, mac} {
|
|
if res := m.sync(); res.Pushed != 0 || res.Pulled != 0 {
|
|
t.Fatalf("merged file keeps syncing: %+v", res)
|
|
}
|
|
}
|
|
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), path)
|
|
if err != nil || len(revisions) != 3 {
|
|
t.Fatalf("want base, dell and one merged revision, got %d (%v)", len(revisions), err)
|
|
}
|
|
}
|
|
|
|
func TestConflictingOrBinaryEditsFallBackToLaterWins(t *testing.T) {
|
|
for name, versions := range map[string][3]string{
|
|
"same line": {"1\n2\n3\n4\n5\n", "mac\n2\n3\n4\n5\n", "dell\n2\n3\n4\n5\n"},
|
|
"nul byte": {"1\n2\n3\n4\n5\n\x00\n", "mac\n2\n3\n4\n5\n\x00\n", "1\n2\n3\n4\ndell\n\x00\n"},
|
|
"invalid utf8": {"1\n2\n3\n4\n5\n\xff\n", "mac\n2\n3\n4\n5\n\xff\n", "1\n2\n3\n4\ndell\n\xff\n"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
path := "docs/agents/plan.md"
|
|
write(t, mac.root, path, versions[0])
|
|
mac.sync()
|
|
dell.sync()
|
|
|
|
write(t, mac.root, path, versions[1])
|
|
old := time.Now().Add(-time.Hour)
|
|
if err := os.Chtimes(filepath.Join(mac.root, path), old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write(t, dell.root, path, versions[2])
|
|
dell.sync()
|
|
mac.sync()
|
|
dell.sync()
|
|
|
|
mac.expect(path, versions[2])
|
|
dell.expect(path, versions[2])
|
|
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), path)
|
|
if err != nil || len(revisions) != 4 {
|
|
t.Fatalf("want base, dell, losing mac and restored dell revisions, got %d (%v)", len(revisions), err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRulesFileTravelsAndHostileServerPathsAreIgnored(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
write(t, mac.root, RulesFile, "shared notes\n")
|
|
write(t, mac.root, "notes/a.md", "a")
|
|
mac.sync()
|
|
dell.sync()
|
|
dell.expect("notes/a.md", "a")
|
|
if status := git(t, dell.root, "status", "--porcelain"); status != "" {
|
|
t.Fatalf("synced rules did not reach exclude: %s", status)
|
|
}
|
|
|
|
hash, _ := mac.client.cacheBlob([]byte("#!/bin/sh"))
|
|
if err := mac.client.uploadBlob(context.Background(), hash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []string{".git/hooks/pre-commit", "notes/../../escape", "/etc/passwd"} {
|
|
push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: path, Hash: hash}}}
|
|
err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil)
|
|
if err == nil {
|
|
t.Fatalf("server accepted hostile path %s", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIgnoredFilesNeverTravel(t *testing.T) {
|
|
mac, dell := setup(t)
|
|
rules := "shared vault\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore vault/cache\n"
|
|
write(t, mac.root, RulesFile, rules)
|
|
write(t, mac.root, "vault/note.md", "note")
|
|
write(t, mac.root, "vault/.obsidian/app.json", "settings")
|
|
write(t, mac.root, "vault/.obsidian/workspace.json", "mac layout")
|
|
write(t, mac.root, "vault/.obsidian/workspace-mobile.json", "mac mobile")
|
|
write(t, mac.root, "vault/deep/.DS_Store", "finder")
|
|
write(t, mac.root, "vault/cache/index.bin", "cache")
|
|
if res := mac.sync(); res.Pushed != 3 {
|
|
t.Fatalf("pushed %d files, want the rules, the note and app.json", res.Pushed)
|
|
}
|
|
|
|
write(t, dell.root, "vault/.obsidian/workspace.json", "dell layout")
|
|
dell.sync()
|
|
dell.expect("vault/note.md", "note")
|
|
dell.expect("vault/.obsidian/app.json", "settings")
|
|
dell.expect("vault/.obsidian/workspace.json", "dell layout")
|
|
dell.expect("vault/.obsidian/workspace-mobile.json", "")
|
|
dell.expect("vault/deep/.DS_Store", "")
|
|
dell.expect("vault/cache/index.bin", "")
|
|
|
|
hash, _ := mac.client.cacheBlob([]byte("pushed by an old client"))
|
|
if err := mac.client.uploadBlob(context.Background(), hash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: "vault/.obsidian/workspace.json", Hash: hash}}}
|
|
if err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mac.sync()
|
|
dell.sync()
|
|
mac.expect("vault/.obsidian/workspace.json", "mac layout")
|
|
dell.expect("vault/.obsidian/workspace.json", "dell layout")
|
|
|
|
exclude, _ := os.ReadFile(filepath.Join(dell.root, ".git/info/exclude"))
|
|
if strings.Contains(string(exclude), "workspace") || strings.Contains(string(exclude), "DS_Store") {
|
|
t.Fatalf("ignore rules leaked into exclude:\n%s", exclude)
|
|
}
|
|
}
|
|
|
|
func TestIgnoreRuleMatching(t *testing.T) {
|
|
rules := parseRules("shared vault\nbranch .agent\nignore vault/.obsidian/workspace*.json\nignore .DS_Store\nignore *.tmp\nignore .lodge\n")
|
|
for file, want := range map[string]bool{
|
|
"vault/note.md": true,
|
|
"vault/.obsidian/workspace.json": false,
|
|
"vault/.obsidian/workspace-mobile.json": false,
|
|
"vault/.obsidian/workspaces.json": false,
|
|
"vault/.obsidian/app.json": true,
|
|
"vault/sub/.obsidian/workspace.json": true,
|
|
"vault/.DS_Store": false,
|
|
"vault/a/b/.DS_Store": false,
|
|
"vault/.DS_Store/inside": false,
|
|
"vault/draft.tmp": false,
|
|
RulesFile: true,
|
|
} {
|
|
if got := rules.covers(file, true); got != want {
|
|
t.Errorf("covers(%s) = %v, want %v", file, got, want)
|
|
}
|
|
}
|
|
if rules.covers(".agent/.DS_Store", false) || !rules.covers(".agent/plan.md", false) {
|
|
t.Error("ignore rules must apply to branch paths too")
|
|
}
|
|
}
|
|
|
|
func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) {
|
|
mac, _ := setup(t)
|
|
write(t, mac.root, "docs/agents/plan.md", "v1")
|
|
mac.sync()
|
|
|
|
if err := mac.client.call(context.Background(), http.MethodDelete, "/api/repos/"+repoKey(t, mac), nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mac.sync()
|
|
mac.expect("docs/agents/plan.md", "v1")
|
|
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "")
|
|
if err != nil || len(revisions) != 1 {
|
|
t.Fatalf("want a fresh history of one revision, got %d (%v)", len(revisions), err)
|
|
}
|
|
}
|
|
|
|
func TestAdminSessionCookie(t *testing.T) {
|
|
mac, _ := setup(t)
|
|
res, err := http.Post(mac.client.URL+"/api/session", "application/json", strings.NewReader(`{"password":"`+adminToken+`"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent || len(res.Cookies()) != 1 {
|
|
t.Fatalf("login failed: %d", res.StatusCode)
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, mac.client.URL+"/api/machines", nil)
|
|
req.AddCookie(res.Cookies()[0])
|
|
got, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got.Body.Close()
|
|
if got.StatusCode != http.StatusOK {
|
|
t.Fatalf("cookie was not accepted: %d", got.StatusCode)
|
|
}
|
|
}
|
|
|
|
func repoKey(t *testing.T, m *machine) string {
|
|
t.Helper()
|
|
return git(t, m.root, "rev-list", "--max-parents=0", "HEAD")[:40]
|
|
}
|