package main import ( "bytes" "io" "net/http" "net/http/httptest" "net/http/httputil" "net/url" "strings" "testing" "time" ) // proxyTo stands a server running h where git-forge would be, for the length of // the test, configured as proxy.go configures it so that a test drives the path // a deployment does. func proxyTo(t *testing.T, h http.HandlerFunc) { t.Helper() forge := httptest.NewServer(h) t.Cleanup(forge.Close) target, err := url.Parse(forge.URL) if err != nil { t.Fatal(err) } saved := proxy proxy = &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(target) }, FlushInterval: -1, } t.Cleanup(func() { proxy = saved }) } // TestInflight pins what the idle alarm asks for: the count has to hold until // the last byte of a response. func TestInflight(t *testing.T) { started, release := make(chan struct{}), make(chan struct{}) proxyTo(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.(http.Flusher).Flush() close(started) <-release }) front := httptest.NewServer(newSync(nil, t.TempDir())) defer front.Close() count := func() string { resp, err := http.Get(front.URL + inflightPath) if err != nil { t.Fatal(err) } defer resp.Body.Close() out, _ := io.ReadAll(resp.Body) return strings.TrimSpace(string(out)) } if got := count(); got != "0" { t.Errorf("with nothing happening the count is %q, want 0", got) } done := make(chan struct{}) go func() { defer close(done) resp, err := http.Get(front.URL + "/notes.git/git-upload-pack") if err == nil { _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() } }() <-started if got := count(); got != "1" { t.Errorf("while a response is still streaming the count is %q, want 1", got) } close(release) <-done // The decrement is deferred, so it lands just after the client is served. for deadline := time.Now().Add(time.Second); ; { if count() == "0" { break } if time.Now().After(deadline) { t.Errorf("the count never came back to 0") break } time.Sleep(5 * time.Millisecond) } } // TestInflightPathIsExact pins that only the literal spelling is answered here: // the worker refuses this prefix undecoded, so an escaped one has to go on. func TestInflightPathIsExact(t *testing.T) { proxyTo(t, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "no such page\n", http.StatusNotFound) }) front := httptest.NewServer(newSync(nil, t.TempDir())) defer front.Close() resp, err := http.Get(front.URL + "/%2Esyncd/inflight") if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusNotFound { t.Errorf("an escaped %s was answered with %s %q, want it passed on", inflightPath, resp.Status, body) } } // TestHeldResponseIsBounded pins what a held response may weigh. cgit answers // any method, so a snapshot asked for with POST is a read that arrives here as // a write, and holding it would be the repository in memory. func TestHeldResponseIsBounded(t *testing.T) { body := bytes.Repeat([]byte("x"), 64<<10) var weight int proxyTo(t, func(w http.ResponseWriter, r *http.Request) { for sent := 0; sent < weight; sent += len(body) { _, _ = w.Write(body) } }) // A real bucket, so that the flush a write triggers succeeds and the only // thing under test is the size of the answer. _, bucketURL := newBucket(t) s := newSync(newR2(bucketURL), t.TempDir()) if err := s.restore(); err != nil { t.Fatal(err) } front := httptest.NewServer(s) defer front.Close() post := func() (int, int) { resp, err := http.Post(front.URL+"/notes/snapshot/notes-master.tar.gz", "text/plain", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() out, _ := io.ReadAll(resp.Body) return resp.StatusCode, len(out) } weight = maxHeld - len(body) if status, n := post(); status != http.StatusOK || n != weight { t.Errorf("a response under the bound came back %d with %d bytes, want 200 with %d", status, n, weight) } weight = maxHeld + len(body) if status, _ := post(); status != http.StatusServiceUnavailable { t.Errorf("a response over the bound came back %d, want it refused", status) } } // TestDeleteDoesNotHydrate pins the one exception to hydrating on any request // that names a repository. git-forge removes the directories without reading // them, so fetching first would be the whole repository onto disk on its way // out. The exception costs nothing if it is lost, which is why it is pinned. func TestDeleteDoesNotHydrate(t *testing.T) { gitIdentity(t) b, bucketURL := newBucket(t) seed(t, b, bucketURL) proxyTo(t, func(w http.ResponseWriter, r *http.Request) {}) s := newSync(newR2(bucketURL), t.TempDir()) if err := s.restore(); err != nil { t.Fatal(err) } front := httptest.NewServer(s) defer front.Close() post := func(path string) { resp, err := http.Post(front.URL+path, "text/plain", nil) if err != nil { t.Fatal(err) } resp.Body.Close() } post("/x/settings/delete") if s.hydrated("x.git") { t.Error("the delete route fetched the repository it was about to remove") } post("/x/settings") if !s.hydrated("x.git") { t.Error("every other route still has to hydrate") } } // TestRepoOf pins the matcher that decides what a request hydrates. Matching // something that is not a repository is wasted work; missing one serves an // empty directory, which git reports as a fatal rather than an empty repository. func TestRepoOf(t *testing.T) { s := newSync(nil, t.TempDir()) s.setManifest([]string{"notes.git", "notes.issues.git", "notes.wiki.git", "team/api.git"}) cases := []struct { path string want string }{ {"/notes.git/info/refs", "notes"}, {"/notes.git/git-upload-pack", "notes"}, {"/notes/tree/README", "notes"}, {"/notes", "notes"}, {"/notes.issues.git/info/refs", "notes"}, {"/notes.wiki.git/info/refs", "notes"}, {"/team/api.git/info/refs", "team/api"}, {"/team/api/log", "team/api"}, {"/team", ""}, {"/settings", ""}, {"/", ""}, {"/nosuch.git/info/refs", ""}, } for _, c := range cases { base, ok := s.repoOf(c.path) if !ok { base = "" } if base != c.want { t.Errorf("repoOf(%q) = %q, want %q", c.path, base, c.want) } } } // TestIsRead pins the rule that decides what is streamed and what is held. A // mistake costs latency rather than correctness, except in the two directions // that are not symmetric: a clone held is a gigabyte in memory, and the two get // routes that edit users.json would be answered before they are durable. func TestIsRead(t *testing.T) { cases := []struct { method, path string want bool }{ {"GET", "/notes.git/info/refs", true}, {"GET", "/notes/tree/README", true}, {"HEAD", "/notes/tree/README", true}, {"POST", "/notes.git/git-upload-pack", true}, {"POST", "/notes.git/git-receive-pack", false}, {"GET", "/confirm-email", false}, {"GET", "/oidc/google", false}, {"GET", "/oidc/google/callback", false}, {"POST", "/settings/tokens", false}, {"POST", "/admin/repo/create", false}, } for _, c := range cases { if got := isRead(httptest.NewRequest(c.method, c.path, nil)); got != c.want { t.Errorf("isRead(%s %s) = %v, want %v", c.method, c.path, got, c.want) } } }