435 lines
12 KiB
Go
435 lines
12 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"
|
|
"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))
|
|
}
|