package forge import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "os/exec" "strings" "testing" ) type apiClient struct { t *testing.T h http.Handler } func (a apiClient) do(method, path, auth, body string) *httptest.ResponseRecorder { a.t.Helper() r := httptest.NewRequest(method, path, strings.NewReader(body)) r.RemoteAddr = "192.0.2.7:1000" if body != "" && strings.HasPrefix(strings.TrimSpace(body), "{") { r.Header.Set("Content-Type", "application/json") } switch { case strings.HasPrefix(auth, "basic:"): name, pass, _ := strings.Cut(strings.TrimPrefix(auth, "basic:"), ":") r.SetBasicAuth(name, pass) case strings.HasPrefix(auth, "cookie:"): r.Header.Set("Cookie", sessionCookie+"="+strings.TrimPrefix(auth, "cookie:")) case auth != "": r.Header.Set("Authorization", "Bearer "+auth) } w := httptest.NewRecorder() a.h.ServeHTTP(w, r) return w } func (a apiClient) want(code int, method, path, auth, body string) map[string]any { a.t.Helper() w := a.do(method, path, auth, body) if w.Code != code { a.t.Fatalf("%s %s: want %d, got %d %s", method, path, code, w.Code, w.Body.String()) } out := map[string]any{} if w.Body.Len() > 0 && strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") { if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { a.t.Fatalf("%s %s: %v", method, path, err) } } return out } func items(v map[string]any) []any { list, _ := v["items"].([]any) return list } func TestAPI(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } s, h := testServer(t) a := apiClient{t, h} alice, _ := s.store.CreateUser("alice", "correct horse") bob, _ := s.store.CreateUser("bob", "battery staple") age(t, s, alice) age(t, s, bob) at, _ := s.store.CreateToken(alice.ID, "all", scopeAll) bt, _ := s.store.CreateToken(bob.ID, "all", scopeAll) rt, _ := s.store.CreateToken(alice.ID, "reader", scopeRead) gt, _ := s.store.CreateToken(alice.ID, "gists", scopeGist) index := a.want(200, "GET", "/api", "", "") if routes, _ := index["routes"].([]any); len(routes) < 80 { t.Errorf("index lists %d routes", len(routes)) } r := httptest.NewRequest("GET", "/api", nil) r.Header.Set("Accept", "text/html") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != 200 || !strings.Contains(w.Body.String(), "/~:owner/:repo/pulls/:number/merge") { t.Errorf("html index: %d", w.Code) } a.want(401, "GET", "/api/me", "", "") a.want(401, "GET", "/api/me", "gp_nonsense", "") me := a.want(200, "GET", "/api/me", at, "") if me["handle"] != "~alice" || me["scope"] != "all" || me["via"] != "token" { t.Errorf("me: %v", me) } if v := a.want(200, "GET", "/api/me", "basic:alice:"+at, ""); v["via"] != "token" { t.Errorf("basic token: %v", v) } a.want(404, "GET", "/api/nowhere", at, "") if w := a.do("PUT", "/api/me", at, "{}"); w.Code != 405 || !strings.Contains(w.Header().Get("Allow"), "PATCH") { t.Errorf("405: %d %v", w.Code, w.Header()) } if w := a.do("OPTIONS", "/api/me", "", ""); w.Code != 204 || w.Header().Get("Access-Control-Allow-Origin") != "*" { t.Errorf("preflight: %d", w.Code) } a.want(403, "POST", "/api/repos", rt, `{"name":"nope"}`) a.want(403, "POST", "/api/repos", gt, `{"name":"nope"}`) a.want(403, "POST", "/api/me/tokens", at, `{"title":"x"}`) tok := a.want(201, "POST", "/api/me/tokens", "basic:alice:correct horse", `{"title":"cli","scope":"read"}`) if !strings.HasPrefix(tok["token"].(string), "gp_") || tok["scope"] != "read" { t.Errorf("token: %v", tok) } a.want(400, "POST", "/api/me/tokens", "basic:alice:correct horse", `{"scope":"root"}`) a.want(400, "POST", "/api/repos", at, `{"name":"x","bogus":1}`) login := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, "") cookie := session(login) if v := a.want(200, "GET", "/api/me", "cookie:"+cookie, ""); v["via"] != "session" { t.Errorf("session: %v", v) } r = httptest.NewRequest("GET", "/api/me", nil) r.Header.Set("Cookie", sessionCookie+"="+cookie) r.Header.Set("Sec-Fetch-Site", "cross-site") w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != 401 { t.Errorf("cross-site cookie accepted: %d", w.Code) } repo := a.want(201, "POST", "/api/repos", at, `{"name":"proj","description":"a project","tags":["ocaml"]}`) if repo["path"] != "~alice/proj" || repo["visibility"] != "public" { t.Fatalf("repo: %v", repo) } a.want(404, "GET", "/api/~alice/proj/tree/main", at, "") g := Git{Dir: s.repoDir(Repo{Owner: alice, Name: "proj"})} base := commitFile(t, g, "main", "", "README.md", "# proj\n\nhello\n") a.want(200, "PATCH", "/api/~alice/proj", at, `{"description":"renamed soon","visibility":"unlisted"}`) a.want(403, "PATCH", "/api/~alice/proj", bt, `{"description":"mine"}`) put := a.want(201, "PUT", "/api/~alice/proj/tree/main/src/lib.ml", at, `{"content":"let x = 1\n","message":"add lib"}`) if put["subject"] != "add lib" { t.Errorf("put: %v", put) } a.want(409, "PUT", "/api/~alice/proj/tree/main/src/lib.ml", at, `{"content":"let x = 2\n","parent":"`+base+`"}`) bin := base64.StdEncoding.EncodeToString([]byte{0, 1, 2, 255}) a.want(201, "PUT", "/api/~alice/proj/tree/main/blob.bin", at, `{"content":"`+bin+`","encoding":"base64"}`) a.want(403, "PUT", "/api/~alice/proj/tree/main/x", bt, `{"content":"x"}`) dir := a.want(200, "GET", "/api/~alice/proj/tree/main", "", "") if entries, _ := dir["entries"].([]any); len(entries) != 3 { t.Errorf("tree: %v", dir) } file := a.want(200, "GET", "/api/~alice/proj/tree/main/src/lib.ml", "", "") if file["content"] != "let x = 1\n" || file["encoding"] != "utf-8" { t.Errorf("file: %v", file) } if f := a.want(200, "GET", "/api/~alice/proj/tree/main/blob.bin", "", ""); f["encoding"] != "base64" || f["content"] != bin { t.Errorf("binary: %v", f) } if w := a.do("GET", "/api/~alice/proj/raw/main/src/lib.ml", "", ""); w.Body.String() != "let x = 1\n" { t.Errorf("raw: %q", w.Body.String()) } log := a.want(200, "GET", "/api/~alice/proj/log/main?limit=2", "", "") if len(items(log)) != 2 || log["next"] == nil { t.Errorf("log: %v", log) } commit := a.want(200, "GET", "/api/~alice/proj/commit/"+base+"?patch=1", "", "") if files, _ := commit["files"].([]any); len(files) != 1 { t.Errorf("commit: %v", commit) } a.want(200, "GET", "/api/~alice/proj/blame/main/README.md", "", "") if rd := a.want(200, "GET", "/api/~alice/proj/readme", "", ""); !strings.Contains(rd["html"].(string), "