Files
byre/internal/server/server_test.go
T

353 lines
9.0 KiB
Go

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"
)
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 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)
}
done := api.Task{}
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, &done)
if code != http.StatusOK || done.Status != api.Completed || done.CompletedAt == nil {
t.Fatalf("complete: %d %+v", code, done)
}
_, 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+"/complete", nil, nil)
if code != http.StatusConflict {
t.Fatalf("want 409 completing twice, 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 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+"/complete", nil, nil)
if live := resumed.next(); live.Kind != "task.completed" {
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))
}