Files

252 lines
7.4 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
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 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]
}