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 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 setup(t *testing.T) (*machine, *machine) { 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) 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") machines := [2]*machine{} for i := range machines { root := filepath.Join(t.TempDir(), "clone") git(t, origin, "clone", origin, root) resolved, _ := filepath.EvalSymlinks(root) machines[i] = &machine{t: t, root: resolved, client: &Client{URL: api.URL, Token: adminToken, DataDir: t.TempDir()}} } return machines[0], machines[1] } 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.client.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.client.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 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] }