aboutsummaryrefslogtreecommitdiff
path: root/proxy_test.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commit3284b1c4906b90ca2e3864937c66d0fa30772ebf (patch)
treead799407ac7b0a2589a344751f4c5b3cf575d5c5 /proxy_test.go
downloadgit-forge-cloudflare-3284b1c4906b90ca2e3864937c66d0fa30772ebf.tar.gz
Enter git-forge-cloudflareHEADmaster
Runs git-forge on Cloudflare Containers with R2 for durable storage. A Worker routes every request to one container through a Durable Object. Inside it, syncd mirrors the data directory to the bucket and restores it at boot, holding a write until it is durable. git-forge is neither modified nor vendored: the image builds it from its own repository at the commit named in forge.rev.
Diffstat (limited to 'proxy_test.go')
-rw-r--r--proxy_test.go249
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)
+ }
+ }
+}