diff options
Diffstat (limited to 'proxy_test.go')
| -rw-r--r-- | proxy_test.go | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/proxy_test.go b/proxy_test.go new file mode 100644 index 0000000..1e5a0e6 --- /dev/null +++ b/proxy_test.go @@ -0,0 +1,249 @@ +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) + } + } +} |