From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree. --- cgit_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 cgit_test.go (limited to 'cgit_test.go') diff --git a/cgit_test.go b/cgit_test.go new file mode 100644 index 0000000..1d62694 --- /dev/null +++ b/cgit_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// Every cgit page forks a child, and browsing a public repository takes no +// credential, so the budget is what stands between a visitor and one child per +// connection. A request that cannot have a slot waits for one, but not on +// behalf of somebody who has already hung up. +func TestBrowsingIsBounded(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled}) + h := f.routes() + + for range cap(browsing) { + browsing <- struct{}{} + } + defer func() { + for range cap(browsing) { + <-browsing + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan struct{}) + go func() { + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/notes/", nil).WithContext(ctx)) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("a browse held a goroutine waiting to fork for a client that had gone") + } +} + +// A tab that leads to a 404 is worse than no tab. One template draws the row +// for both kinds of page, so both are asked here. +func TestTabsFollowTheKnobs(t *testing.T) { + f := testForge(t) + addRepo(t, f, "docs", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) + addRepo(t, f, "code", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + for _, c := range []struct { + path, page string + issues, wiki bool + }{ + {"docs", "/docs/wiki", false, true}, + {"code", "/code/issues", true, false}, + } { + repo, _ := f.store.Repo(c.path) + r := httptest.NewRequest("GET", "/"+c.path+"/", nil) + rows := []struct{ where, html string }{ + {"injected into cgit's row", string(f.navPart(r, "tabs", c.path, "", repo))}, + {"on our own page", get(h, c.page, nil).Body.String()}, + } + for _, row := range rows { + for _, tab := range []struct { + name string + want bool + }{{"issues", c.issues}, {"wiki", c.wiki}} { + got := strings.Contains(row.html, ">"+tab.name+"") + if got != tab.want { + t.Errorf("%s %s: the %s tab is present %v, wanted %v", + c.path, row.where, tab.name, got, tab.want) + } + } + } + } +} + +// The bar is spliced into cgit's own markup by matching it, so a cgit whose +// page has changed shape has to fail here rather than quietly serve a page +// with no bar on it. +func TestTheBarGoesInsideCgitsPage(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + if _, err := os.Stat(cgitBinary()); err != nil { + t.Skip("cgit is not installed, and this is a test of cgit's markup") + } + page := get(f.routes(), "/notes/", nil).Body.String() + + div := strings.Index(page, string(cgitDiv)) + header := strings.Index(page, ``) + cell := strings.Index(page, string(cgitTabs)) + tabs := strings.Index(page, `>issues`) + switch { + case div < 0: + t.Fatalf("cgit no longer opens its page with %s:\n%s", cgitDiv, page) + case cell < 0: + t.Fatalf("cgit no longer ends its tab cell with %s:\n%s", cgitTabs, page) + case header < 0: + t.Error("the header was not injected") + case header < div: + t.Error("the header landed outside cgit's own div") + case tabs < 0: + t.Error("the forge's tabs were not injected") + case tabs > cell: + t.Error("the forge's tabs landed after cgit's own tab cell rather than in it") + } + if n := strings.Count(page, ``); n != 1 { + t.Errorf("the header went in %d times", n) + } +} + +// Only text/html is held back, and only until there is nothing left to wait +// for: a snapshot has to stream through whole and untouched. +func TestTheInjectorPassesThroughWhatItCannotPlace(t *testing.T) { + body := bytes.Repeat([]byte("x"), 3*maxInject) + for _, kind := range []string{"text/html; charset=utf-8", "application/x-gzip"} { + w := httptest.NewRecorder() + w.Header().Set("Content-Type", kind) + nav := &injector{ResponseWriter: w, nav: []byte("NAV"), tabs: []byte("TABS")} + nav.WriteHeader(http.StatusOK) + if _, err := nav.Write(body); err != nil { + t.Fatalf("%s: %v", kind, err) + } + nav.flush() + if got := w.Body.Bytes(); !bytes.Equal(got, body) { + t.Errorf("%s: %d bytes came back, of the %d that went in", kind, len(got), len(body)) + } + } +} + +// Bodies are handed to goldmark straight and the result is trusted as HTML, so +// leaving raw HTML unrendered is the whole reason that is safe. +func TestMarkdownIsNotHTML(t *testing.T) { + cases := map[string]string{ + "": "alert", + "": "onerror", + "[click](javascript:alert(1))": "javascript:", + "[click](data:text/html,%3Cs)": "data:", + "hello": "vbscript:", + } + for source, forbidden := range cases { + out := string(renderMarkdown(source)) + if strings.Contains(out, forbidden) { + t.Errorf("%q rendered to %q, which still carries %q", source, out, forbidden) + } + } +} -- cgit v1.2.3