package server_test import ( "bufio" "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "testing/fstest" "time" "g.fc.run/misc/byre/internal/api" "g.fc.run/misc/byre/internal/server" "g.fc.run/misc/byre/web" ) const login = "alice@example.com" type harness struct { t *testing.T url string } func newHarness(t *testing.T) *harness { srv, err := server.New(t.TempDir(), fstest.MapFS{"index.html": {Data: []byte("shell")}}) if err != nil { t.Fatalf("new server: %v", err) } t.Cleanup(func() { srv.Close() }) hs := httptest.NewServer(srv.Handler()) t.Cleanup(hs.Close) return &harness{t: t, url: hs.URL} } func (h *harness) do(method, path string, body any, out any) int { h.t.Helper() buf := &bytes.Buffer{} if body != nil { err := json.NewEncoder(buf).Encode(body) if err != nil { h.t.Fatalf("encode: %v", err) } } req, err := http.NewRequest(method, h.url+path, buf) if err != nil { h.t.Fatalf("new request: %v", err) } req.Header.Set("Tailscale-User-Login", login) req.Header.Set("Tailscale-User-Name", "Alice") resp, err := http.DefaultClient.Do(req) if err != nil { h.t.Fatalf("%s %s: %v", method, path, err) } defer resp.Body.Close() if out != nil && resp.StatusCode < 300 { err = json.NewDecoder(resp.Body).Decode(out) if err != nil { h.t.Fatalf("decode %s %s: %v", method, path, err) } } return resp.StatusCode } func (h *harness) createProject(name string) api.Project { h.t.Helper() p := api.Project{} code := h.do("POST", "/api/projects", api.ProjectCreate{Name: name, RepoPath: repoWithOrigin(h.t)}, &p) if code != http.StatusCreated { h.t.Fatalf("create project: %d", code) } return p } func TestIdentityRequired(t *testing.T) { h := newHarness(t) resp, err := http.Get(h.url + "/api/me") if err != nil { t.Fatalf("get: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("want 401 without tailscale headers, got %d", resp.StatusCode) } resp, err = http.Get(h.url + "/") if err != nil { t.Fatalf("get spa: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("want spa shell without auth, got %d", resp.StatusCode) } } func TestServesEmbeddedSPA(t *testing.T) { srv, err := server.New(t.TempDir(), web.FS) if err != nil { t.Fatalf("new server: %v", err) } t.Cleanup(func() { srv.Close() }) hs := httptest.NewServer(srv.Handler()) t.Cleanup(hs.Close) for path, contentType := range map[string]string{ "/": "text/html", "/style.css": "text/css", "/dist/app.js": "text/javascript", "/dist/app.js.map": "", "/src/app.ts": "", "/dist/inbox.js": "text/javascript", "/dist/api.gen.js": "text/javascript", "/dist/tasks.js": "text/javascript", "/dist/navbar.js": "text/javascript", "/dist/panes.js": "text/javascript", "/dist/task.js": "text/javascript", "/dist/state.js": "text/javascript", "/dist/dom.js": "text/javascript", "/dist/api.js": "text/javascript", "/fonts/atkinson-hyperlegible-mono-latin-wght-normal.woff2": "font/woff2", "/fonts/LICENSE": "", } { resp, err := http.Get(hs.URL + path) if err != nil { t.Fatalf("get %s: %v", path, err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("get %s: %d", path, resp.StatusCode) } if contentType != "" && !strings.HasPrefix(resp.Header.Get("Content-Type"), contentType) { t.Fatalf("get %s: content type %q, want %s", path, resp.Header.Get("Content-Type"), contentType) } } } func TestMe(t *testing.T) { h := newHarness(t) me := api.User{} h.do("GET", "/api/me", nil, &me) if me.Login != login || me.DisplayName != "Alice" || me.GitEmail != "" { t.Fatalf("unexpected user %+v", me) } code := h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "nope"}, nil) if code != http.StatusBadRequest { t.Fatalf("want 400 for bad email, got %d", code) } code = h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice A", GitEmail: "alice@example.org"}, &me) if code != http.StatusOK || me.GitName != "Alice A" || me.GitEmail != "alice@example.org" { t.Fatalf("update failed: %d %+v", code, me) } } func TestProjectAndTaskLifecycle(t *testing.T) { h := newHarness(t) p := h.createProject("demo") if p.DefaultBranch != "main" || !strings.HasSuffix(p.Remote, "origin.git") { t.Fatalf("unexpected project %+v", p) } code := h.do("POST", "/api/projects", api.ProjectCreate{Name: "demo", RepoPath: p.RepoPath}, nil) if code != http.StatusBadRequest { t.Fatalf("want 400 for duplicate project, got %d", code) } task := api.Task{} code = h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "first"}, &task) if code != http.StatusCreated { t.Fatalf("create task: %d", code) } _, err := os.Stat(filepath.Join(task.WorktreePath, "README")) if err != nil { t.Fatalf("worktree not checked out: %v", err) } head := gitOut(t, task.WorktreePath, "rev-parse", "--abbrev-ref", "HEAD") if head != task.Branch { t.Fatalf("worktree on %s, want %s", head, task.Branch) } if task.BaseRef != gitOut(t, p.RepoPath, "rev-parse", "main") { t.Fatalf("base ref %s is not main's head", task.BaseRef) } tasks := []api.Task{} h.do("GET", "/api/projects/"+p.Id+"/tasks", nil, &tasks) if len(tasks) != 1 || tasks[0].Id != task.Id { t.Fatalf("unexpected task list %+v", tasks) } err = os.WriteFile(filepath.Join(task.WorktreePath, "dirty"), []byte("x"), 0o644) if err != nil { t.Fatalf("write: %v", err) } unmerged := api.Unmerged{} h.do("GET", "/api/tasks/"+task.Id+"/unmerged", nil, &unmerged) if unmerged != (api.Unmerged{Commits: 0, Files: 1, Insertions: 1, Deletions: 0, Uncommitted: 1}) { t.Fatalf("unexpected unmerged %+v", unmerged) } done := api.Task{} code = h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, &done) if code != http.StatusOK || done.Status != api.TaskStatusDiscarded || done.ClosedAt == nil || done.MergeCommit != nil { t.Fatalf("discard: %d %+v", code, done) } if gitOut(t, p.RepoPath, "rev-parse", "main") != task.BaseRef { t.Fatalf("discard moved main") } _, err = os.Stat(task.WorktreePath) if !os.IsNotExist(err) { t.Fatalf("worktree still exists: %v", err) } branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch) if branches != "" { t.Fatalf("branch still exists: %s", branches) } code = h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil) if code != http.StatusConflict { t.Fatalf("want 409 discarding twice, got %d", code) } code = h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil) if code != http.StatusConflict { t.Fatalf("want 409 merging a discarded task, got %d", code) } code = h.do("GET", "/api/tasks/nope", nil, nil) if code != http.StatusNotFound { t.Fatalf("want 404 for unknown task, got %d", code) } } func TestMergeTask(t *testing.T) { h := newHarness(t) p := h.createProject("demo") task := api.Task{} h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "Add notes"}, &task) code := h.do("POST", "/api/tasks/"+task.Id+"/merge", nil, nil) if code != http.StatusBadRequest { t.Fatalf("want 400 merging without a git identity, got %d", code) } got := api.Task{} h.do("GET", "/api/tasks/"+task.Id, nil, &got) if got.Status != api.TaskStatusActive { t.Fatalf("refused merge changed the task: %+v", got) } } func writeFile(t *testing.T, path, content string) { t.Helper() err := os.WriteFile(path, []byte(content), 0o644) if err != nil { t.Fatalf("write %s: %v", path, err) } } func TestEventStreamResume(t *testing.T) { h := newHarness(t) stream := h.openStream(nil) p := h.createProject("demo") first := stream.next() if first.Kind != "project.created" || *first.ProjectId != p.Id { t.Fatalf("unexpected first event %+v", first) } stream.close() task := api.Task{} h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "while away"}, &task) h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil) resumed := h.openStream(&first.Seq) missed := resumed.next() if missed.Kind != "task.created" || *missed.TaskId != task.Id || missed.Seq != first.Seq+1 { t.Fatalf("unexpected replayed event %+v", missed) } if next := resumed.next(); next.Kind != "user.updated" { t.Fatalf("unexpected second replayed event %+v", next) } h.do("POST", "/api/tasks/"+task.Id+"/discard", nil, nil) if live := resumed.next(); live.Kind != "task.discarded" { t.Fatalf("unexpected live event %+v", live) } status := api.Status{} h.do("GET", "/api/admin/status", nil, &status) if status.LastEventSeq != 4 || status.Projects != 1 || status.ActiveTasks != 0 || status.SseClients < 1 { t.Fatalf("unexpected status %+v", status) } } type stream struct { t *testing.T cancel context.CancelFunc resp *http.Response r *bufio.Reader } func (h *harness) openStream(lastEventID *int64) *stream { h.t.Helper() ctx, cancel := context.WithCancel(context.Background()) req, err := http.NewRequestWithContext(ctx, "GET", h.url+"/api/events", nil) if err != nil { h.t.Fatalf("new request: %v", err) } req.Header.Set("Tailscale-User-Login", login) req.Header.Set("Tailscale-User-Name", "Alice") if lastEventID != nil { req.Header.Set("Last-Event-ID", fmt.Sprint(*lastEventID)) } resp, err := http.DefaultClient.Do(req) if err != nil { h.t.Fatalf("open stream: %v", err) } if resp.StatusCode != http.StatusOK { h.t.Fatalf("open stream: %d", resp.StatusCode) } s := &stream{t: h.t, cancel: cancel, resp: resp, r: bufio.NewReader(resp.Body)} h.t.Cleanup(s.close) return s } func (s *stream) next() api.Event { s.t.Helper() type result struct { ev api.Event err error } ch := make(chan result, 1) go func() { id := "" data := "" for { line, err := s.r.ReadString('\n') if err != nil { ch <- result{err: err} return } line = strings.TrimRight(line, "\n") switch { case strings.HasPrefix(line, "id: "): id = strings.TrimPrefix(line, "id: ") case strings.HasPrefix(line, "data: "): data = strings.TrimPrefix(line, "data: ") case line == "" && data != "": ev := api.Event{} err := json.Unmarshal([]byte(data), &ev) if err == nil && fmt.Sprint(ev.Seq) != id { err = fmt.Errorf("sse id %s does not match seq %d", id, ev.Seq) } ch <- result{ev: ev, err: err} return } } }() select { case res := <-ch: if res.err != nil { s.t.Fatalf("read event: %v", res.err) } return res.ev case <-time.After(5 * time.Second): s.t.Fatalf("timed out waiting for event") return api.Event{} } } func (s *stream) close() { s.cancel() s.resp.Body.Close() } func repoWithOrigin(t *testing.T) string { dir := t.TempDir() origin := filepath.Join(dir, "origin.git") repo := filepath.Join(dir, "repo") gitOut(t, dir, "init", "--bare", "--initial-branch=main", origin) gitOut(t, dir, "init", "--initial-branch=main", repo) gitOut(t, repo, "remote", "add", "origin", origin) err := os.WriteFile(filepath.Join(repo, "README"), []byte("hi\n"), 0o644) if err != nil { t.Fatalf("write readme: %v", err) } gitOut(t, repo, "add", "README") gitOut(t, repo, "commit", "--message", "root") gitOut(t, repo, "push", "origin", "main") return repo } func gitOut(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com", ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } return strings.TrimSpace(string(out)) }