detached b0a4fdf

test(e2e): run the real git client against the wired server for AC1-AC8

commit
b0a4fdf3eeacbe5ca9552cc8ce826c2b616e4c71
author
brice · · 2026-10-10 07:10 UTC
parent
979aa64
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +1026 -1
M README.md +12 -1
@@ -2,7 +2,7 @@
22
33 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface.
44
5-Status: early development, not usable yet. `ermite serve` starts, opens its metadata store, reconciles the repositories on disk and answers `GET /-/health`.
5+Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push) and answers `GET /-/health`. There is no web interface or token command yet.
66
77 ## Run
88
@@ -27,6 +27,17 @@ ermite serve
2727
2828 `ermite version` prints the version.
2929
30+## Git over HTTP
31+
32+```sh
33+git clone http://git.example.com/<repo>.git # public repositories need no credentials
34+git push http://u:<token>@git.example.com/<repo>.git main
35+```
36+
37+Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
38+
39+The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.
40+
3041 ## Build
3142
3243 ```sh
@@ -2,7 +2,7 @@
2 2
3 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface.3 A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface.
4 4
5-Status: early development, not usable yet. `ermite serve` starts, opens its metadata store, reconciles the repositories on disk and answers `GET /-/health`.5+Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push) and answers `GET /-/health`. There is no web interface or token command yet.
6 6
7 ## Run7 ## Run
8 8
@@ -27,6 +27,17 @@ ermite serve
27 27
28 `ermite version` prints the version.28 `ermite version` prints the version.
29 29
30+## Git over HTTP
31+
32+```sh
33+git clone http://git.example.com/<repo>.git # public repositories need no credentials
34+git push http://u:<token>@git.example.com/<repo>.git main
35+```
36+
37+Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
38+
39+The server keeps its runtime files in `$ERMITE_DATA_DIR/run/` (the hook socket and scripts that git calls on each push); the hook socket path must stay under about 100 bytes.
40+
30 ## Build41 ## Build
31 42
32 ```sh43 ```sh
A cmd/ermite/e2e_helpers_test.go +218 -0
@@ -0,0 +1,218 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "context"
8+ "net"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/bnema/zerowrap"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/app"
22+ "bnema.dev/ermite/internal/config"
23+ "bnema.dev/ermite/internal/domain"
24+)
25+
26+// harness is a forge wired like `ermite serve`, behind an httptest server,
27+// and a git client isolated from the machine's configuration.
28+type harness struct {
29+ t *testing.T
30+ forge *forge
31+ srv *httptest.Server
32+ data string
33+ logs *syncBuffer
34+ home string // HOME of the git client
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ hooksDone chan error
38+}
39+
40+type harnessOpts struct {
41+ trustedProxies string
42+ maintenance time.Duration
43+ // hookExe is the executable the hook scripts exec; empty builds ermite.
44+ hookExe string
45+}
46+
47+// newHarness starts a forge. The data directory is short: the hook socket
48+// lives in it and Unix socket paths are limited to about 100 bytes.
49+func newHarness(t *testing.T, o harnessOpts) *harness {
50+ t.Helper()
51+ gitBin, err := exec.LookPath("git")
52+ if err != nil {
53+ t.Skip("git not found")
54+ }
55+ data, err := os.MkdirTemp("", "ermite")
56+ require.NoError(t, err)
57+ t.Cleanup(func() { _ = os.RemoveAll(data) })
58+
59+ env := map[string]string{
60+ config.EnvDataDir: data,
61+ config.EnvPublicURL: "http://example.test",
62+ config.EnvAdminToken: adminToken,
63+ config.EnvGit: gitBin,
64+ config.EnvAddr: "127.0.0.1:1",
65+ }
66+ if o.trustedProxies != "" {
67+ env[config.EnvTrustedProxies] = o.trustedProxies
68+ }
69+ cfg, err := config.Load(func(k string) string { return env[k] })
70+ require.NoError(t, err)
71+
72+ logs := &syncBuffer{}
73+ log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: logs})
74+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), log))
75+
76+ require.NoError(t, prepareDataDir(data))
77+ hookExe := o.hookExe
78+ if hookExe == "" {
79+ hookExe = binary(t)
80+ }
81+ f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance})
82+ require.NoError(t, err)
83+
84+ h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
85+ go func() { h.hooksDone <- f.hooks.Serve(ctx); close(h.hooksDone) }()
86+ h.srv = httptest.NewUnstartedServer(f.server.Handler())
87+ h.srv.Config.BaseContext = func(net.Listener) context.Context { return ctx }
88+ h.srv.Start()
89+
90+ h.home = t.TempDir()
91+ t.Cleanup(func() {
92+ h.srv.Close()
93+ cancel()
94+ select {
95+ case <-h.hooksDone:
96+ case <-time.After(10 * time.Second):
97+ t.Error("hook server did not stop")
98+ }
99+ f.Close(context.WithoutCancel(ctx))
100+ })
101+ return h
102+}
103+
104+func (h *harness) url(path string) string { return h.srv.URL + path }
105+
106+// urlAuth returns a URL with the token as the password.
107+func (h *harness) urlAuth(secret, path string) string {
108+ return strings.Replace(h.srv.URL, "http://", "http://user:"+secret+"@", 1) + path
109+}
110+
111+func (h *harness) name(s string) domain.RepoName {
112+ h.t.Helper()
113+ n, err := domain.ParseRepoName(s)
114+ require.NoError(h.t, err)
115+ return n
116+}
117+
118+// git runs the git client in dir with an isolated configuration.
119+func (h *harness) git(dir string, args ...string) (string, error) {
120+ h.t.Helper()
121+ cmd := exec.Command("git", args...)
122+ cmd.Dir = dir
123+ cmd.Env = []string{
124+ "PATH=" + os.Getenv("PATH"),
125+ "HOME=" + h.home,
126+ "GIT_CONFIG_GLOBAL=/dev/null",
127+ "GIT_CONFIG_NOSYSTEM=1",
128+ "GIT_TERMINAL_PROMPT=0",
129+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.test",
130+ "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.test",
131+ "LC_ALL=C",
132+ }
133+ var out bytes.Buffer
134+ cmd.Stdout, cmd.Stderr = &out, &out
135+ err := cmd.Run()
136+ return out.String(), err
137+}
138+
139+func (h *harness) mustGit(dir string, args ...string) string {
140+ h.t.Helper()
141+ out, err := h.git(dir, args...)
142+ require.NoError(h.t, err, "git %s\n%s", strings.Join(args, " "), out)
143+ return out
144+}
145+
146+// workTree makes a work tree with one commit per file on branch.
147+func (h *harness) workTree(branch string, files ...string) string {
148+ h.t.Helper()
149+ dir := h.t.TempDir()
150+ h.mustGit(dir, "init", "-q", "--initial-branch="+branch)
151+ for _, f := range files {
152+ require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(dir, f)), 0o755))
153+ require.NoError(h.t, os.WriteFile(filepath.Join(dir, f), []byte("content of "+f+"\n"), 0o644))
154+ h.mustGit(dir, "add", f)
155+ h.mustGit(dir, "commit", "-q", "-m", "add "+f)
156+ }
157+ return dir
158+}
159+
160+// status does a plain HTTP request and returns the status code.
161+func (h *harness) status(method, path string, hdr http.Header) int {
162+ h.t.Helper()
163+ req, err := http.NewRequest(method, h.url(path), nil)
164+ require.NoError(h.t, err)
165+ for k, vs := range hdr {
166+ for _, v := range vs {
167+ req.Header.Add(k, v)
168+ }
169+ }
170+ resp, err := http.DefaultClient.Do(req)
171+ require.NoError(h.t, err)
172+ _ = resp.Body.Close()
173+ return resp.StatusCode
174+}
175+
176+// token creates a stored token through the use case, as root.
177+func (h *harness) token(scopes []domain.Scope, repos ...string) string {
178+ h.t.Helper()
179+ p := app.CreateTokenParams{Label: "e2e", Scopes: scopes}
180+ for _, r := range repos {
181+ p.Repos = append(p.Repos, h.name(r))
182+ }
183+ got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
184+ require.NoError(h.t, err)
185+ return got.Secret
186+}
187+
188+// gitEnv runs git like git() with extra environment variables.
189+func (h *harness) gitEnv(extra []string, dir string, args ...string) (string, error) {
190+ h.t.Helper()
191+ cmd := exec.Command("git", args...)
192+ cmd.Dir = dir
193+ cmd.Env = append([]string{
194+ "PATH=" + os.Getenv("PATH"),
195+ "HOME=" + h.home,
196+ "GIT_CONFIG_GLOBAL=/dev/null",
197+ "GIT_CONFIG_NOSYSTEM=1",
198+ "GIT_TERMINAL_PROMPT=0",
199+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.test",
200+ "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.test",
201+ "LC_ALL=C",
202+ }, extra...)
203+ var out bytes.Buffer
204+ cmd.Stdout, cmd.Stderr = &out, &out
205+ err := cmd.Run()
206+ return out.String(), err
207+}
208+
209+// repoDir is the bare repository on disk.
210+func (h *harness) repoDir(name string) string {
211+ return filepath.Join(h.data, "repos", name+".git")
212+}
213+
214+// bareGit runs git against the bare repository on disk.
215+func (h *harness) bareGit(name string, args ...string) string {
216+ h.t.Helper()
217+ return strings.TrimSpace(h.mustGit(h.repoDir(name), args...))
218+}
@@ -0,0 +1,218 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "context"
8+ "net"
9+ "net/http"
10+ "net/http/httptest"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/bnema/zerowrap"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/app"
22+ "bnema.dev/ermite/internal/config"
23+ "bnema.dev/ermite/internal/domain"
24+)
25+
26+// harness is a forge wired like `ermite serve`, behind an httptest server,
27+// and a git client isolated from the machine's configuration.
28+type harness struct {
29+ t *testing.T
30+ forge *forge
31+ srv *httptest.Server
32+ data string
33+ logs *syncBuffer
34+ home string // HOME of the git client
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ hooksDone chan error
38+}
39+
40+type harnessOpts struct {
41+ trustedProxies string
42+ maintenance time.Duration
43+ // hookExe is the executable the hook scripts exec; empty builds ermite.
44+ hookExe string
45+}
46+
47+// newHarness starts a forge. The data directory is short: the hook socket
48+// lives in it and Unix socket paths are limited to about 100 bytes.
49+func newHarness(t *testing.T, o harnessOpts) *harness {
50+ t.Helper()
51+ gitBin, err := exec.LookPath("git")
52+ if err != nil {
53+ t.Skip("git not found")
54+ }
55+ data, err := os.MkdirTemp("", "ermite")
56+ require.NoError(t, err)
57+ t.Cleanup(func() { _ = os.RemoveAll(data) })
58+
59+ env := map[string]string{
60+ config.EnvDataDir: data,
61+ config.EnvPublicURL: "http://example.test",
62+ config.EnvAdminToken: adminToken,
63+ config.EnvGit: gitBin,
64+ config.EnvAddr: "127.0.0.1:1",
65+ }
66+ if o.trustedProxies != "" {
67+ env[config.EnvTrustedProxies] = o.trustedProxies
68+ }
69+ cfg, err := config.Load(func(k string) string { return env[k] })
70+ require.NoError(t, err)
71+
72+ logs := &syncBuffer{}
73+ log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: logs})
74+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), log))
75+
76+ require.NoError(t, prepareDataDir(data))
77+ hookExe := o.hookExe
78+ if hookExe == "" {
79+ hookExe = binary(t)
80+ }
81+ f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance})
82+ require.NoError(t, err)
83+
84+ h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
85+ go func() { h.hooksDone <- f.hooks.Serve(ctx); close(h.hooksDone) }()
86+ h.srv = httptest.NewUnstartedServer(f.server.Handler())
87+ h.srv.Config.BaseContext = func(net.Listener) context.Context { return ctx }
88+ h.srv.Start()
89+
90+ h.home = t.TempDir()
91+ t.Cleanup(func() {
92+ h.srv.Close()
93+ cancel()
94+ select {
95+ case <-h.hooksDone:
96+ case <-time.After(10 * time.Second):
97+ t.Error("hook server did not stop")
98+ }
99+ f.Close(context.WithoutCancel(ctx))
100+ })
101+ return h
102+}
103+
104+func (h *harness) url(path string) string { return h.srv.URL + path }
105+
106+// urlAuth returns a URL with the token as the password.
107+func (h *harness) urlAuth(secret, path string) string {
108+ return strings.Replace(h.srv.URL, "http://", "http://user:"+secret+"@", 1) + path
109+}
110+
111+func (h *harness) name(s string) domain.RepoName {
112+ h.t.Helper()
113+ n, err := domain.ParseRepoName(s)
114+ require.NoError(h.t, err)
115+ return n
116+}
117+
118+// git runs the git client in dir with an isolated configuration.
119+func (h *harness) git(dir string, args ...string) (string, error) {
120+ h.t.Helper()
121+ cmd := exec.Command("git", args...)
122+ cmd.Dir = dir
123+ cmd.Env = []string{
124+ "PATH=" + os.Getenv("PATH"),
125+ "HOME=" + h.home,
126+ "GIT_CONFIG_GLOBAL=/dev/null",
127+ "GIT_CONFIG_NOSYSTEM=1",
128+ "GIT_TERMINAL_PROMPT=0",
129+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.test",
130+ "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.test",
131+ "LC_ALL=C",
132+ }
133+ var out bytes.Buffer
134+ cmd.Stdout, cmd.Stderr = &out, &out
135+ err := cmd.Run()
136+ return out.String(), err
137+}
138+
139+func (h *harness) mustGit(dir string, args ...string) string {
140+ h.t.Helper()
141+ out, err := h.git(dir, args...)
142+ require.NoError(h.t, err, "git %s\n%s", strings.Join(args, " "), out)
143+ return out
144+}
145+
146+// workTree makes a work tree with one commit per file on branch.
147+func (h *harness) workTree(branch string, files ...string) string {
148+ h.t.Helper()
149+ dir := h.t.TempDir()
150+ h.mustGit(dir, "init", "-q", "--initial-branch="+branch)
151+ for _, f := range files {
152+ require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(dir, f)), 0o755))
153+ require.NoError(h.t, os.WriteFile(filepath.Join(dir, f), []byte("content of "+f+"\n"), 0o644))
154+ h.mustGit(dir, "add", f)
155+ h.mustGit(dir, "commit", "-q", "-m", "add "+f)
156+ }
157+ return dir
158+}
159+
160+// status does a plain HTTP request and returns the status code.
161+func (h *harness) status(method, path string, hdr http.Header) int {
162+ h.t.Helper()
163+ req, err := http.NewRequest(method, h.url(path), nil)
164+ require.NoError(h.t, err)
165+ for k, vs := range hdr {
166+ for _, v := range vs {
167+ req.Header.Add(k, v)
168+ }
169+ }
170+ resp, err := http.DefaultClient.Do(req)
171+ require.NoError(h.t, err)
172+ _ = resp.Body.Close()
173+ return resp.StatusCode
174+}
175+
176+// token creates a stored token through the use case, as root.
177+func (h *harness) token(scopes []domain.Scope, repos ...string) string {
178+ h.t.Helper()
179+ p := app.CreateTokenParams{Label: "e2e", Scopes: scopes}
180+ for _, r := range repos {
181+ p.Repos = append(p.Repos, h.name(r))
182+ }
183+ got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
184+ require.NoError(h.t, err)
185+ return got.Secret
186+}
187+
188+// gitEnv runs git like git() with extra environment variables.
189+func (h *harness) gitEnv(extra []string, dir string, args ...string) (string, error) {
190+ h.t.Helper()
191+ cmd := exec.Command("git", args...)
192+ cmd.Dir = dir
193+ cmd.Env = append([]string{
194+ "PATH=" + os.Getenv("PATH"),
195+ "HOME=" + h.home,
196+ "GIT_CONFIG_GLOBAL=/dev/null",
197+ "GIT_CONFIG_NOSYSTEM=1",
198+ "GIT_TERMINAL_PROMPT=0",
199+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.test",
200+ "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.test",
201+ "LC_ALL=C",
202+ }, extra...)
203+ var out bytes.Buffer
204+ cmd.Stdout, cmd.Stderr = &out, &out
205+ err := cmd.Run()
206+ return out.String(), err
207+}
208+
209+// repoDir is the bare repository on disk.
210+func (h *harness) repoDir(name string) string {
211+ return filepath.Join(h.data, "repos", name+".git")
212+}
213+
214+// bareGit runs git against the bare repository on disk.
215+func (h *harness) bareGit(name string, args ...string) string {
216+ h.t.Helper()
217+ return strings.TrimSpace(h.mustGit(h.repoDir(name), args...))
218+}
A cmd/ermite/e2e_test.go +796 -0
@@ -0,0 +1,796 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "net/http"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "testing"
18+ "time"
19+
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "bnema.dev/ermite/internal/app"
24+ "bnema.dev/ermite/internal/domain"
25+ "bnema.dev/ermite/internal/ports"
26+)
27+
28+func scopes(s ...domain.Scope) []domain.Scope { return s }
29+
30+// publicRepo creates a public repository with one commit pushed by root.
31+func (h *harness) publicRepo(name string, files ...string) {
32+ h.t.Helper()
33+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
34+ require.NoError(h.t, err)
35+ wt := h.workTree("main", files...)
36+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
37+}
38+
39+// AC1: anonymous clone of a public repository works; a private one gives
40+// 401 and then 404 with a valid unrelated token, like a missing repository.
41+func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) {
42+ h := newHarness(t, harnessOpts{})
43+ h.publicRepo("pub", "README.md", "docs/a.txt")
44+
45+ // A private repository, pushed by root.
46+ wt := h.workTree("main", "secret.txt")
47+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
48+
49+ dir := t.TempDir()
50+ out, err := h.git(dir, "clone", "-q", h.url("/pub.git"), "pub")
51+ require.NoError(t, err, out)
52+ assert.FileExists(t, filepath.Join(dir, "pub", "docs", "a.txt"))
53+ // The .git suffix is optional.
54+ out, err = h.git(dir, "clone", "-q", h.url("/pub"), "pub2")
55+ require.NoError(t, err, out)
56+
57+ // Private and missing look the same to an anonymous client: 401 + challenge.
58+ for _, name := range []string{"priv", "nothing-here"} {
59+ req, _ := http.NewRequest(http.MethodGet, h.url("/"+name+".git/info/refs?service=git-upload-pack"), nil)
60+ resp, err := http.DefaultClient.Do(req)
61+ require.NoError(t, err)
62+ _ = resp.Body.Close()
63+ assert.Equal(t, 401, resp.StatusCode, name)
64+ assert.Equal(t, `Basic realm="ermite"`, resp.Header.Get("WWW-Authenticate"), name)
65+ }
66+ out, err = h.git(dir, "clone", "-q", h.url("/priv.git"), "nope")
67+ require.Error(t, err)
68+ assert.NotContains(t, out, "secret.txt")
69+ assert.NoDirExists(t, filepath.Join(dir, "nope"))
70+
71+ // A valid token without access gets "not found", the same as a missing repository.
72+ other := h.token(scopes(domain.ScopeRepoRead), "pub")
73+ out, err = h.git(dir, "clone", "-q", h.urlAuth(other, "/priv.git"), "nope")
74+ require.Error(t, err)
75+ assert.Contains(t, out, "not found")
76+ out2, err := h.git(dir, "clone", "-q", h.urlAuth(other, "/nothing-here.git"), "nope2")
77+ require.Error(t, err)
78+ assert.Contains(t, out2, "not found")
79+
80+ // A reader of the repository, and root, can clone it.
81+ reader := h.token(scopes(domain.ScopeRepoRead), "priv")
82+ for _, secret := range []string{reader, adminToken} {
83+ d := t.TempDir()
84+ out, err = h.git(d, "clone", "-q", h.urlAuth(secret, "/priv.git"), "c")
85+ require.NoError(t, err, out)
86+ assert.FileExists(t, filepath.Join(d, "c", "secret.txt"))
87+ }
88+ // A token with repo:read for every repository, passed as a Bearer header.
89+ all := h.token(scopes(domain.ScopeRepoRead))
90+ d := t.TempDir()
91+ out, err = h.git(d, "-c", "http.extraHeader=Authorization: Bearer "+all, "clone", "-q", h.url("/priv.git"), "c")
92+ require.NoError(t, err, out)
93+}
94+
95+// Only the smart HTTP routes exist; the rest of the repository tree is 404.
96+func TestRoutes(t *testing.T) {
97+ h := newHarness(t, harnessOpts{})
98+ h.publicRepo("pub", "a")
99+
100+ for _, path := range []string{
101+ "/pub.git/HEAD",
102+ "/pub.git/objects/info/packs",
103+ "/pub.git/info/refs", // dumb protocol: no service
104+ "/pub.git/info/refs?service=git-bogus",
105+ "/pub.git/config",
106+ "/pub/tree/main",
107+ "/",
108+ "/-/nothing",
109+ "/Bad.git/info/refs?service=git-upload-pack",
110+ "/..%2f..%2fetc/info/refs?service=git-upload-pack",
111+ } {
112+ assert.Equal(t, 404, h.status("GET", path, nil), path)
113+ }
114+ assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
115+ assert.Equal(t, 405, h.status("GET", "/pub.git/git-upload-pack", nil))
116+ assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
117+ assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
118+ // Anonymous receive-pack is a challenge even for a public repository.
119+ assert.Equal(t, 401, h.status("GET", "/pub.git/info/refs?service=git-receive-pack", nil))
120+ assert.Equal(t, 401, h.status("POST", "/pub.git/git-receive-pack", nil))
121+}
122+
123+// AC2: who may push.
124+func TestAC2PushAuthorization(t *testing.T) {
125+ h := newHarness(t, harnessOpts{})
126+ wt := h.workTree("main", "a.txt")
127+
128+ // Root creates the repository.
129+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
130+ require.NoError(t, err, out)
131+
132+ writer := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "proj")
133+ writerAll := h.token(scopes(domain.ScopeRepoWrite))
134+ reader := h.token(scopes(domain.ScopeRepoRead))
135+ elsewhere := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "other")
136+
137+ next := func(file string) {
138+ require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
139+ h.mustGit(wt, "add", file)
140+ h.mustGit(wt, "commit", "-q", "-m", file)
141+ }
142+
143+ for _, secret := range []string{writer, writerAll, adminToken} {
144+ next("w-" + secret[len(secret)-6:])
145+ out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/proj.git"), "main")
146+ require.NoError(t, err, out)
147+ }
148+
149+ // A reader is known but may not write: 403.
150+ next("by-reader")
151+ out, err = h.git(wt, "push", "-q", h.urlAuth(reader, "/proj.git"), "main")
152+ require.Error(t, err)
153+ assert.Contains(t, out, "403")
154+
155+ // A token limited to another repository cannot even see this private one.
156+ out, err = h.git(wt, "push", "-q", h.urlAuth(elsewhere, "/proj.git"), "main")
157+ require.Error(t, err)
158+ assert.Contains(t, out, "not found")
159+
160+ // ... and it may not create a repository outside its list.
161+ _, err = h.git(wt, "push", "-q", h.urlAuth(elsewhere, "/brand-new.git"), "main")
162+ require.Error(t, err)
163+ assert.NoDirExists(t, h.repoDir("brand-new"))
164+
165+ // Nothing from the refused pushes arrived.
166+ assert.NotContains(t, h.bareGit("proj", "log", "--format=%s", "main"), "by-reader")
167+
168+ // Garbage credentials: 401.
169+ out, err = h.git(wt, "push", "-q", h.urlAuth("ermite_not-a-token", "/proj.git"), "main")
170+ require.Error(t, err)
171+ assert.Contains(t, out, "Authentication failed")
172+
173+ // Expired token (written to the store directly: the use case refuses a past expiry).
174+ expired := "ermite_expired-secret-0123456789"
175+ require.NoError(t, h.forge.store.Tokens().Create(h.ctx, domain.Token{
176+ ID: "expired1", Label: "old", Hash: domain.HashTokenSecret(expired),
177+ Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-time.Hour),
178+ }))
179+ out, err = h.git(wt, "push", "-q", h.urlAuth(expired, "/proj.git"), "main")
180+ require.Error(t, err)
181+ assert.Contains(t, out, "Authentication failed")
182+
183+ // Revoked token.
184+ created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)})
185+ require.NoError(t, err)
186+ next("before-revoke")
187+ out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
188+ require.NoError(t, err, out)
189+ require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
190+ next("after-revoke")
191+ out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
192+ require.Error(t, err)
193+ assert.Contains(t, out, "Authentication failed")
194+ assert.NotContains(t, h.bareGit("proj", "log", "--format=%s", "main"), "after-revoke")
195+
196+ // Anonymous pushes are a plain 401.
197+ _, err = h.git(wt, "push", "-q", h.url("/proj.git"), "main")
198+ require.Error(t, err)
199+
200+ // The token store index stays consistent with revocation (AC8).
201+ _, err = h.forge.store.Tokens().FindByHash(h.ctx, domain.HashTokenSecret(created.Secret))
202+ assert.ErrorIs(t, err, domain.ErrNotFound)
203+ list, err := h.forge.Tokens.List(h.ctx, domain.Root())
204+ require.NoError(t, err)
205+ for _, tok := range list {
206+ _, err := h.forge.store.Tokens().FindByHash(h.ctx, tok.Hash)
207+ assert.NoError(t, err, tok.ID)
208+ }
209+}
210+
211+// AC3: first push creates a private repository with HEAD on the pushed branch.
212+func TestAC3CreateOnPush(t *testing.T) {
213+ h := newHarness(t, harnessOpts{})
214+ events := h.forge.Events.Subscribe(h.ctx)
215+
216+ // Only "dev" is pushed: HEAD (initially main) moves to it.
217+ wt := h.workTree("dev", "a")
218+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/only-dev.git"), "dev")
219+ require.NoError(t, err, out)
220+ assert.Equal(t, "refs/heads/dev", h.bareGit("only-dev", "symbolic-ref", "HEAD"))
221+
222+ repo, err := h.forge.Repos.Get(h.ctx, domain.Root(), h.name("only-dev"))
223+ require.NoError(t, err)
224+ assert.Equal(t, domain.VisibilityPrivate, repo.Visibility)
225+ assert.False(t, repo.PushedAt.IsZero(), "pushed_at is set by post-receive")
226+ _, err = h.forge.Repos.Get(h.ctx, domain.Anonymous(), h.name("only-dev"))
227+ assert.ErrorIs(t, err, domain.ErrNotFound, "private by default")
228+
229+ var created domain.RepoCreated
230+ select {
231+ case ev := <-events:
232+ created = ev.(domain.RepoCreated)
233+ case <-time.After(5 * time.Second):
234+ t.Fatal("no RepoCreated event")
235+ }
236+ assert.Equal(t, h.name("only-dev"), created.Repo)
237+ assert.Equal(t, domain.VisibilityPrivate, created.Visibility)
238+
239+ // Both main and dev are pushed: main is preferred.
240+ wt2 := h.workTree("dev", "a")
241+ h.mustGit(wt2, "branch", "main")
242+ out, err = h.git(wt2, "push", "-q", h.urlAuth(adminToken, "/both.git"), "dev", "main")
243+ require.NoError(t, err, out)
244+ assert.Equal(t, "refs/heads/main", h.bareGit("both", "symbolic-ref", "HEAD"))
245+
246+ // An existing valid HEAD is left alone by later pushes.
247+ h.mustGit(wt2, "checkout", "-q", "-b", "feature")
248+ out, err = h.git(wt2, "push", "-q", h.urlAuth(adminToken, "/both.git"), "feature")
249+ require.NoError(t, err, out)
250+ assert.Equal(t, "refs/heads/main", h.bareGit("both", "symbolic-ref", "HEAD"))
251+
252+ // An invalid name: 404 and nothing created, by a token that could create.
253+ before, err := h.forge.Repos.List(h.ctx, domain.Root())
254+ require.NoError(t, err)
255+ for _, bad := range []string{"/Upper.git", "/has%20space.git", "/-.git", "/robots.txt", "/a..b.git"} {
256+ assert.Equal(t, 404, h.statusAuth("GET", bad+"/info/refs?service=git-receive-pack", adminToken), bad)
257+ }
258+ after, err := h.forge.Repos.List(h.ctx, domain.Root())
259+ require.NoError(t, err)
260+ assert.Equal(t, before, after)
261+ entries, err := os.ReadDir(filepath.Join(h.data, "repos"))
262+ require.NoError(t, err)
263+ for _, e := range entries {
264+ assert.NotContains(t, strings.ToLower(e.Name()), "upper")
265+ }
266+
267+ // A fetch of an unknown repository never creates it, even for root.
268+ assert.Equal(t, 404, h.statusAuth("GET", "/ghost.git/info/refs?service=git-upload-pack", adminToken))
269+ assert.NoDirExists(t, h.repoDir("ghost"))
270+ // Neither does the second request of a push.
271+ assert.Equal(t, 404, h.statusAuth("POST", "/ghost.git/git-receive-pack", adminToken))
272+ assert.NoDirExists(t, h.repoDir("ghost"))
273+}
274+
275+// statusAuth is status with the token as Basic credentials.
276+func (h *harness) statusAuth(method, path, secret string) int {
277+ h.t.Helper()
278+ req, err := http.NewRequest(method, h.url(path), nil)
279+ require.NoError(h.t, err)
280+ req.SetBasicAuth("x", secret)
281+ resp, err := http.DefaultClient.Do(req)
282+ require.NoError(h.t, err)
283+ _ = resp.Body.Close()
284+ return resp.StatusCode
285+}
286+
287+// AC4: hooks fail closed, and post-receive records exactly what was pushed.
288+func TestAC4HooksFailClosed(t *testing.T) {
289+ h := newHarness(t, harnessOpts{})
290+ wt := h.workTree("main", "a")
291+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
292+ require.NoError(t, err, out)
293+ before := h.bareGit("proj", "rev-parse", "main")
294+
295+ // The hook socket goes away: pre-receive cannot ask ermite and rejects.
296+ h.forge.hooks.Close()
297+ select {
298+ case <-h.hooksDone:
299+ case <-time.After(5 * time.Second):
300+ t.Fatal("hook server did not stop")
301+ }
302+ require.NoError(t, os.WriteFile(filepath.Join(wt, "b"), []byte("b"), 0o644))
303+ h.mustGit(wt, "add", "b")
304+ h.mustGit(wt, "commit", "-q", "-m", "b")
305+ out, err = h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
306+ require.Error(t, err)
307+ assert.Contains(t, out, "unreachable")
308+ assert.Contains(t, out, "rejected")
309+ assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"), "the ref did not move")
310+}
311+
312+func TestAC4HooksUnknownTokenAndMissingBinary(t *testing.T) {
313+ // The hook scripts point at a binary that does not exist: git cannot run
314+ // the hook, so the push fails (it never succeeds unchecked).
315+ h := newHarness(t, harnessOpts{hookExe: "/nonexistent/ermite"})
316+ wt := h.workTree("main", "a")
317+ out, err := h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
318+ require.Error(t, err, out)
319+ _, gerr := os.Stat(h.repoDir("proj"))
320+ require.NoError(t, gerr, "the repository was created by the first request")
321+ assert.Empty(t, h.bareGit("proj", "for-each-ref"), "nothing was accepted")
322+}
323+
324+func TestAC4PostReceiveAttribution(t *testing.T) {
325+ h := newHarness(t, harnessOpts{})
326+ events := h.forge.Events.Subscribe(h.ctx)
327+
328+ // Two repositories, pushed at the same time with different refs.
329+ const n = 4
330+ type job struct {
331+ repo, branch string
332+ wt string
333+ }
334+ var jobs []job
335+ for i := range n {
336+ repo := "repo" + string(rune('a'+i))
337+ branch := "branch-" + string(rune('a'+i))
338+ jobs = append(jobs, job{repo: repo, branch: branch, wt: h.workTree(branch, "f-"+repo)})
339+ }
340+ var wg sync.WaitGroup
341+ for _, j := range jobs {
342+ wg.Add(1)
343+ go func() {
344+ defer wg.Done()
345+ out, err := h.git(j.wt, "push", "-q", h.urlAuth(adminToken, "/"+j.repo+".git"), j.branch)
346+ assert.NoError(t, err, out)
347+ }()
348+ }
349+ wg.Wait()
350+
351+ got := map[domain.RepoName]domain.PushEvent{}
352+ deadline := time.After(10 * time.Second)
353+ for len(got) < n {
354+ select {
355+ case ev := <-events:
356+ if pe, ok := ev.(domain.PushEvent); ok {
357+ require.NotContains(t, got, pe.Repo, "one event per push")
358+ got[pe.Repo] = pe
359+ }
360+ case <-deadline:
361+ t.Fatalf("got %d push events, want %d", len(got), n)
362+ }
363+ }
364+ for _, j := range jobs {
365+ pe := got[h.name(j.repo)]
366+ require.Len(t, pe.Updates, 1, j.repo)
367+ assert.Equal(t, "refs/heads/"+j.branch, pe.Updates[0].Ref, j.repo)
368+ assert.Equal(t, domain.ZeroOID, pe.Updates[0].Old)
369+ assert.Equal(t, h.bareGit(j.repo, "rev-parse", j.branch), pe.Updates[0].New, "the exact new object id")
370+ repo, err := h.forge.Repos.Get(h.ctx, domain.Root(), h.name(j.repo))
371+ require.NoError(t, err)
372+ assert.False(t, repo.PushedAt.IsZero())
373+ }
374+
375+ // A second push reports the old id, a deletion reports the zero id.
376+ wt := jobs[0].wt
377+ old := h.bareGit(jobs[0].repo, "rev-parse", jobs[0].branch)
378+ require.NoError(t, os.WriteFile(filepath.Join(wt, "again"), []byte("x"), 0o644))
379+ h.mustGit(wt, "add", "again")
380+ h.mustGit(wt, "commit", "-q", "-m", "again")
381+ h.mustGit(wt, "tag", "v1")
382+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/"+jobs[0].repo+".git"), jobs[0].branch, "v1")
383+ require.NoError(t, err, out)
384+ pe := waitPush(t, events)
385+ require.Len(t, pe.Updates, 2)
386+ byRef := map[string]domain.RefUpdate{}
387+ for _, u := range pe.Updates {
388+ byRef[u.Ref] = u
389+ }
390+ assert.Equal(t, old, byRef["refs/heads/"+jobs[0].branch].Old)
391+ assert.True(t, byRef["refs/tags/v1"].IsCreate())
392+
393+ out, err = h.git(wt, "push", "-q", h.urlAuth(adminToken, "/"+jobs[0].repo+".git"), ":refs/tags/v1")
394+ require.NoError(t, err, out)
395+ pe = waitPush(t, events)
396+ require.Len(t, pe.Updates, 1)
397+ assert.True(t, pe.Updates[0].IsDelete())
398+}
399+
400+func waitPush(t *testing.T, events <-chan domain.Event) domain.PushEvent {
401+ t.Helper()
402+ deadline := time.After(10 * time.Second)
403+ for {
404+ select {
405+ case ev := <-events:
406+ if pe, ok := ev.(domain.PushEvent); ok {
407+ return pe
408+ }
409+ case <-deadline:
410+ t.Fatal("no PushEvent")
411+ }
412+ }
413+}
414+
415+// AC5: the failed-authentication limiter, with and without trusted proxies.
416+func TestAC5Limiter(t *testing.T) {
417+ t.Run("direct clients", func(t *testing.T) {
418+ h := newHarness(t, harnessOpts{})
419+ bad := func(hdr http.Header) int {
420+ req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
421+ req.SetBasicAuth("u", "ermite_wrong")
422+ for k, v := range hdr {
423+ req.Header[k] = v
424+ }
425+ resp, err := http.DefaultClient.Do(req)
426+ require.NoError(t, err)
427+ _ = resp.Body.Close()
428+ return resp.StatusCode
429+ }
430+ for i := range 10 {
431+ require.Equal(t, 401, bad(nil), "failure %d", i+1)
432+ }
433+ assert.Equal(t, 429, bad(nil), "the 11th request is blocked")
434+ // Even good credentials are blocked while the address is.
435+ assert.Equal(t, 429, h.statusAuth("GET", "/x.git/info/refs?service=git-upload-pack", adminToken))
436+ assert.Equal(t, 429, h.status("GET", "/x.git/info/refs?service=git-upload-pack", nil), "anonymous too")
437+ // X-Forwarded-For from an untrusted peer is ignored.
438+ assert.Equal(t, 429, bad(http.Header{"X-Forwarded-For": {"198.51.100.9"}}))
439+ // Non-Git routes are not limited.
440+ assert.Equal(t, 200, h.status("GET", "/-/health", nil))
441+
442+ resp, err := http.Get(h.url("/x.git/info/refs?service=git-upload-pack"))
443+ require.NoError(t, err)
444+ _ = resp.Body.Close()
445+ assert.NotEmpty(t, resp.Header.Get("Retry-After"))
446+ })
447+
448+ t.Run("trusted proxy", func(t *testing.T) {
449+ h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
450+ bad := func(xff string) int {
451+ req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
452+ req.SetBasicAuth("u", "ermite_wrong")
453+ req.Header.Set("X-Forwarded-For", xff)
454+ resp, err := http.DefaultClient.Do(req)
455+ require.NoError(t, err)
456+ _ = resp.Body.Close()
457+ return resp.StatusCode
458+ }
459+ // A spoofed left entry does not help: the right-most untrusted one counts.
460+ for i := range 10 {
461+ require.Equal(t, 401, bad("1.1.1."+string(rune('0'+i))+", 198.51.100.7"), "failure %d", i+1)
462+ }
463+ assert.Equal(t, 429, bad("2.2.2.2, 198.51.100.7"), "client 198.51.100.7 is blocked")
464+ assert.Equal(t, 401, bad("198.51.100.8"), "another client behind the proxy is not")
465+ req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
466+ req.Header.Set("X-Forwarded-For", "198.51.100.7")
467+ resp, err := http.DefaultClient.Do(req)
468+ require.NoError(t, err)
469+ _ = resp.Body.Close()
470+ assert.Equal(t, 429, resp.StatusCode)
471+ })
472+
473+ t.Run("successes do not count", func(t *testing.T) {
474+ h := newHarness(t, harnessOpts{})
475+ for range 25 {
476+ assert.Equal(t, 404, h.statusAuth("GET", "/x.git/info/refs?service=git-upload-pack", adminToken))
477+ }
478+ // Anonymous requests for a private/missing repo are challenges, not failures.
479+ for range 25 {
480+ assert.Equal(t, 401, h.status("GET", "/x.git/info/refs?service=git-upload-pack", nil))
481+ }
482+ })
483+}
484+
485+// AC6: protocol v2 and partial clone.
486+func TestAC6ProtocolV2AndPartialClone(t *testing.T) {
487+ h := newHarness(t, harnessOpts{})
488+ h.publicRepo("pub", "README.md", "src/main.go", "src/util.go")
489+
490+ dir := t.TempDir()
491+ out, err := h.gitEnv([]string{"GIT_TRACE_PACKET=1"}, dir, "-c", "protocol.version=2", "clone", "-q", h.url("/pub.git"), "v2")
492+ require.NoError(t, err, out)
493+ assert.Contains(t, out, "version 2", "the server speaks protocol v2")
494+ assert.Contains(t, out, "ls-refs")
495+
496+ // Protocol v0 still works for old clients.
497+ out, err = h.git(dir, "-c", "protocol.version=0", "clone", "-q", h.url("/pub.git"), "v0")
498+ require.NoError(t, err, out)
499+
500+ // A later fetch uses v2 as well.
501+ wt := h.workTree("main", "x")
502+ h.mustGit(wt, "push", "-q", "-f", h.urlAuth(adminToken, "/pub.git"), "main")
503+ out, err = h.gitEnv([]string{"GIT_TRACE_PACKET=1"}, filepath.Join(dir, "v2"), "-c", "protocol.version=2", "fetch", "-q")
504+ require.NoError(t, err, out)
505+ assert.Contains(t, out, "fetch=")
506+
507+ // Partial clone: no blobs come down until they are needed.
508+ out, err = h.git(dir, "-c", "protocol.version=2", "clone", "-q", "--no-checkout", "--filter=blob:none", h.url("/pub.git"), "partial")
509+ require.NoError(t, err, out)
510+ partial := filepath.Join(dir, "partial")
511+ assert.Equal(t, "blob:none", strings.TrimSpace(h.mustGit(partial, "config", "remote.origin.partialclonefilter")))
512+ missing := h.mustGit(partial, "rev-list", "--objects", "--missing=print", "--all")
513+ assert.Contains(t, missing, "?", "blobs are missing after the clone")
514+ // ... and are fetched on demand (the promisor remote is the same URL).
515+ out, err = h.git(partial, "checkout", "-q", "main")
516+ require.NoError(t, err, out)
517+ assert.FileExists(t, filepath.Join(partial, "x"))
518+}
519+
520+// AC7: tokens never appear in logs, nor in the environment of the hooks.
521+func TestAC7NoSecretsInLogsOrHookEnvironment(t *testing.T) {
522+ envDump := filepath.Join(t.TempDir(), "hookenv.txt")
523+ bin := binary(t)
524+ // A wrapper that records the environment of the hook, then runs the real hook.
525+ wrapper := filepath.Join(t.TempDir(), "ermite-wrapper")
526+ require.NoError(t, os.WriteFile(wrapper, []byte("#!/bin/sh\nenv >> '"+envDump+"'\nexec '"+bin+"' \"$@\"\n"), 0o755))
527+ h := newHarness(t, harnessOpts{hookExe: wrapper})
528+
529+ stored := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite))
530+ wt := h.workTree("main", "a")
531+ for _, secret := range []string{stored, adminToken} {
532+ out, err := h.git(wt, "push", "-q", "-f", h.urlAuth(secret, "/proj.git"), "main")
533+ require.NoError(t, err, out)
534+ }
535+ // Bad credentials, a Bearer header, and a clone as well.
536+ _, _ = h.git(wt, "push", "-q", h.urlAuth("ermite_secret-that-is-wrong", "/proj.git"), "main")
537+ out, err := h.git(wt, "-c", "http.extraHeader=Authorization: Bearer "+stored, "push", "-q", "-f", h.url("/proj.git"), "main")
538+ require.NoError(t, err, out)
539+ out, err = h.git(t.TempDir(), "clone", "-q", h.urlAuth(stored, "/proj.git"), "c")
540+ require.NoError(t, err, out)
541+
542+ logs := h.logs.String()
543+ require.Contains(t, logs, "push received", "the logs of the pushes were captured")
544+ secrets := []string{stored, adminToken, "ermite_secret-that-is-wrong"}
545+ for _, s := range secrets {
546+ basic := base64.StdEncoding.EncodeToString([]byte("user:" + s))
547+ assert.NotContains(t, logs, s)
548+ assert.NotContains(t, logs, basic)
549+ assert.NotContains(t, logs, base64.StdEncoding.EncodeToString([]byte(s)))
550+ }
551+ assert.NotContains(t, strings.ToLower(logs), "authorization")
552+ assert.NotContains(t, logs, "Basic ")
553+ assert.NotContains(t, logs, "Bearer ")
554+
555+ dumped, err := os.ReadFile(envDump)
556+ require.NoError(t, err)
557+ require.Contains(t, string(dumped), "ERMITE_HOOK_TOKEN=", "the hooks ran with their token")
558+ assert.Contains(t, string(dumped), "ERMITE_HOOK_SOCKET=")
559+ for _, s := range secrets {
560+ assert.NotContains(t, string(dumped), s, "the hook environment")
561+ }
562+ assert.NotContains(t, strings.ToUpper(string(dumped)), "HTTP_AUTHORIZATION")
563+ assert.NotContains(t, strings.ToUpper(string(dumped)), "HTTP_COOKIE")
564+}
565+
566+// AC8, through the wired use cases: no escalation and a consistent index.
567+func TestAC8TokenCreationThroughTheForge(t *testing.T) {
568+ h := newHarness(t, harnessOpts{})
569+ ctx := context.Background()
570+
571+ admin, err := h.forge.Tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
572+ Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")},
573+ })
574+ require.NoError(t, err)
575+ p, err := h.forge.Auth.Authenticate(ctx, admin.Secret)
576+ require.NoError(t, err)
577+
578+ _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}})
579+ assert.ErrorIs(t, err, domain.ErrForbidden, "scope escalation")
580+ _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
581+ assert.ErrorIs(t, err, domain.ErrForbidden, "repository list escalation")
582+ _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}})
583+ assert.ErrorIs(t, err, domain.ErrForbidden, "another repository")
584+ child, err := h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}})
585+ require.NoError(t, err)
586+
587+ // The child works on its repository, and revoking the index entry kills it at once.
588+ wt := h.workTree("main", "f")
589+ out, err := h.git(wt, "push", "-q", h.urlAuth(child.Secret, "/a.git"), "main")
590+ require.NoError(t, err, out)
591+ require.NoError(t, h.forge.Tokens.Revoke(ctx, p, child.Token.ID))
592+ out, err = h.git(wt, "push", "-q", h.urlAuth(child.Secret, "/a.git"), "main")
593+ require.Error(t, err, out)
594+
595+ list, err := h.forge.Tokens.List(ctx, domain.Root())
596+ require.NoError(t, err)
597+ require.Len(t, list, 1)
598+ assert.Equal(t, admin.Token.ID, list[0].ID)
599+ _, err = h.forge.store.Tokens().FindByHash(ctx, child.Token.Hash)
600+ assert.ErrorIs(t, err, domain.ErrNotFound)
601+ var _ ports.TokenStore = h.forge.store.Tokens()
602+}
603+
604+// Large pushes arrive chunked (git's http.postBuffer is 1 MiB): the handler
605+// spools them. Hooks, a path with quotes in the hook executable, and the
606+// debounced maintenance are exercised on the way.
607+func TestLargePushHookPathQuotingAndMaintenance(t *testing.T) {
608+ dir := filepath.Join(t.TempDir(), "it's a \"dir\" with spaces")
609+ require.NoError(t, os.MkdirAll(dir, 0o755))
610+ exe := filepath.Join(dir, "ermite")
611+ raw, err := os.ReadFile(binary(t))
612+ require.NoError(t, err)
613+ require.NoError(t, os.WriteFile(exe, raw, 0o755))
614+
615+ h := newHarness(t, harnessOpts{hookExe: exe, maintenance: 50 * time.Millisecond})
616+ wt := h.workTree("main", "a")
617+ big := make([]byte, 3<<20)
618+ _, err = rand.Read(big)
619+ require.NoError(t, err)
620+ require.NoError(t, os.WriteFile(filepath.Join(wt, "big.bin"), big, 0o644))
621+ h.mustGit(wt, "add", "big.bin")
622+ h.mustGit(wt, "commit", "-q", "-m", "big")
623+
624+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/big.git"), "main")
625+ require.NoError(t, err, out)
626+ assert.Equal(t, h.mustGit(wt, "rev-parse", "main"), h.mustGit(h.repoDir("big"), "rev-parse", "main"))
627+
628+ // Maintenance wrote a commit-graph after the debounce delay.
629+ chain := filepath.Join(h.repoDir("big"), "objects", "info", "commit-graphs", "commit-graph-chain")
630+ require.Eventually(t, func() bool { _, err := os.Stat(chain); return err == nil }, 10*time.Second, 20*time.Millisecond)
631+
632+ // The clone gets the big blob back intact.
633+ d := t.TempDir()
634+ out, err = h.git(d, "clone", "-q", h.urlAuth(adminToken, "/big.git"), "c")
635+ require.NoError(t, err, out)
636+ got, err := os.ReadFile(filepath.Join(d, "c", "big.bin"))
637+ require.NoError(t, err)
638+ assert.Equal(t, big, got)
639+
640+ // Hook scripts: 0755, exec the binary quoted.
641+ for _, hook := range []string{"pre-receive", "post-receive"} {
642+ fi, err := os.Stat(filepath.Join(h.data, "run", "hooks", hook))
643+ require.NoError(t, err)
644+ assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
645+ }
646+ // Nothing was written into the repository itself.
647+ entries, err := os.ReadDir(filepath.Join(h.repoDir("big"), "hooks"))
648+ require.NoError(t, err)
649+ for _, e := range entries {
650+ assert.True(t, strings.HasSuffix(e.Name(), ".sample"), "hooks dir is untouched: %s", e.Name())
651+ }
652+ conf, err := os.ReadFile(filepath.Join(h.repoDir("big"), "config"))
653+ require.NoError(t, err)
654+ assert.NotContains(t, string(conf), "hooksPath")
655+ assert.NotContains(t, string(conf), "receivepack")
656+}
657+
658+// receive.fsckObjects is on: a push with a malformed object is refused, and
659+// nothing is stored in the repository's own configuration to get there.
660+func TestFsckRejectsMalformedObjects(t *testing.T) {
661+ h := newHarness(t, harnessOpts{})
662+ wt := h.workTree("main", "a")
663+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
664+ require.NoError(t, err, out)
665+ before := h.bareGit("proj", "rev-parse", "main")
666+
667+ // A tree whose entries are not sorted: "b" before "a".
668+ blob := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD:a"))
669+ raw := make([]byte, 0, 64)
670+ for _, name := range []string{"b", "a"} {
671+ raw = append(raw, []byte("100644 "+name+"\x00")...)
672+ oid, err := hex.DecodeString(blob)
673+ require.NoError(t, err)
674+ raw = append(raw, oid...)
675+ }
676+ treeFile := filepath.Join(t.TempDir(), "tree")
677+ require.NoError(t, os.WriteFile(treeFile, raw, 0o644))
678+ tree := strings.TrimSpace(h.mustGit(wt, "hash-object", "-w", "-t", "tree", "--literally", treeFile))
679+ commit := strings.TrimSpace(h.mustGit(wt, "commit-tree", "-p", "HEAD", "-m", "malformed", tree))
680+ h.mustGit(wt, "update-ref", "refs/heads/bad", commit)
681+
682+ out, err = h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "bad")
683+ require.Error(t, err, out)
684+ assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"))
685+ assert.Empty(t, h.bareGit("proj", "for-each-ref", "refs/heads/bad"), "the ref was not created")
686+ conf, err := os.ReadFile(filepath.Join(h.repoDir("proj"), "config"))
687+ require.NoError(t, err)
688+ assert.NotContains(t, string(conf), "fsck")
689+}
690+
691+// The built binary end to end: `ermite serve` as a process, its hook scripts
692+// exec'ing the same binary, and a real git client pushing and cloning.
693+func TestBuiltBinaryPushAndClone(t *testing.T) {
694+ gitBin, err := exec.LookPath("git")
695+ if err != nil {
696+ t.Skip("git not found")
697+ }
698+ data, err := os.MkdirTemp("", "ermite")
699+ require.NoError(t, err)
700+ t.Cleanup(func() { _ = os.RemoveAll(data) })
701+ addr := freeAddr(t)
702+
703+ cmd := exec.Command(binary(t), "serve")
704+ cmd.Env = append(baseEnv(),
705+ "ERMITE_ADDR="+addr,
706+ "ERMITE_DATA_DIR="+data,
707+ "ERMITE_PUBLIC_URL=http://"+addr,
708+ "ERMITE_ADMIN_TOKEN="+adminToken,
709+ "ERMITE_GIT="+gitBin,
710+ "ERMITE_LOG_LEVEL=debug",
711+ )
712+ var logs syncBuffer
713+ cmd.Stdout, cmd.Stderr = &logs, &logs
714+ require.NoError(t, cmd.Start())
715+ exited := make(chan error, 1)
716+ go func() { exited <- cmd.Wait() }()
717+ t.Cleanup(func() { _ = cmd.Process.Kill() })
718+
719+ require.Eventually(t, func() bool {
720+ resp, err := http.Get("http://" + addr + "/-/health")
721+ if err != nil {
722+ return false
723+ }
724+ _ = resp.Body.Close()
725+ return resp.StatusCode == 200
726+ }, 15*time.Second, 50*time.Millisecond, logs.String())
727+
728+ home := t.TempDir()
729+ run := func(dir string, args ...string) string {
730+ c := exec.Command("git", args...)
731+ c.Dir = dir
732+ c.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
733+ "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
734+ out, err := c.CombinedOutput()
735+ require.NoError(t, err, "git %v\n%s\nserver logs:\n%s", args, out, logs.String())
736+ return string(out)
737+ }
738+ wt := t.TempDir()
739+ run(wt, "init", "-q", "--initial-branch=main")
740+ require.NoError(t, os.WriteFile(filepath.Join(wt, "hello.txt"), []byte("hello\n"), 0o644))
741+ run(wt, "add", ".")
742+ run(wt, "commit", "-q", "-m", "hello")
743+ url := "http://u:" + adminToken + "@" + addr + "/bin-test.git"
744+ run(wt, "push", "-q", url, "main")
745+ cl := t.TempDir()
746+ run(cl, "clone", "-q", url, "c")
747+ assert.FileExists(t, filepath.Join(cl, "c", "hello.txt"))
748+
749+ // The hooks ran: post-receive recorded the push.
750+ require.Eventually(t, func() bool { return strings.Contains(logs.String(), `"message":"push received"`) }, 5*time.Second, 20*time.Millisecond, logs.String())
751+ assert.NotContains(t, logs.String(), adminToken)
752+
753+ // Hook scripts exec this very binary.
754+ script, err := os.ReadFile(filepath.Join(data, "run", "hooks", "pre-receive"))
755+ require.NoError(t, err)
756+ assert.Contains(t, string(script), binary(t))
757+
758+ require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
759+ select {
760+ case err := <-exited:
761+ require.NoError(t, err, logs.String())
762+ case <-time.After(15 * time.Second):
763+ t.Fatalf("no clean stop\n%s", logs.String())
764+ }
765+ assert.NoFileExists(t, filepath.Join(data, "run", "hook.sock"), "the socket is removed on stop")
766+}
767+
768+// `ermite hook` is hidden, fails closed for pre-receive and never fails the
769+// push for post-receive.
770+func TestHookCommand(t *testing.T) {
771+ bin := binary(t)
772+ input := domainZero + " " + strings.Repeat("a", 40) + " refs/heads/main\n"
773+
774+ run := func(hook string, env ...string) (string, error) {
775+ c := exec.Command(bin, "hook", hook)
776+ c.Env = append(baseEnv(), env...)
777+ c.Stdin = strings.NewReader(input)
778+ out, err := c.CombinedOutput()
779+ return string(out), err
780+ }
781+ out, err := run("pre-receive")
782+ require.Error(t, err)
783+ assert.Contains(t, out, "ERMITE_HOOK_SOCKET")
784+ out, err = run("pre-receive", "ERMITE_HOOK_SOCKET=/nonexistent.sock", "ERMITE_HOOK_TOKEN=x")
785+ require.Error(t, err)
786+ assert.Contains(t, out, "unreachable")
787+ out, err = run("post-receive", "ERMITE_HOOK_SOCKET=/nonexistent.sock", "ERMITE_HOOK_TOKEN=x")
788+ require.NoError(t, err)
789+ assert.Contains(t, out, "post-receive")
790+
791+ help, err := exec.Command(bin, "--help").CombinedOutput()
792+ require.NoError(t, err)
793+ assert.NotContains(t, string(help), "hook", "the command is hidden")
794+}
795+
796+const domainZero = "0000000000000000000000000000000000000000"
@@ -0,0 +1,796 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "net/http"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "syscall"
17+ "testing"
18+ "time"
19+
20+ "github.com/stretchr/testify/assert"
21+ "github.com/stretchr/testify/require"
22+
23+ "bnema.dev/ermite/internal/app"
24+ "bnema.dev/ermite/internal/domain"
25+ "bnema.dev/ermite/internal/ports"
26+)
27+
28+func scopes(s ...domain.Scope) []domain.Scope { return s }
29+
30+// publicRepo creates a public repository with one commit pushed by root.
31+func (h *harness) publicRepo(name string, files ...string) {
32+ h.t.Helper()
33+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
34+ require.NoError(h.t, err)
35+ wt := h.workTree("main", files...)
36+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
37+}
38+
39+// AC1: anonymous clone of a public repository works; a private one gives
40+// 401 and then 404 with a valid unrelated token, like a missing repository.
41+func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) {
42+ h := newHarness(t, harnessOpts{})
43+ h.publicRepo("pub", "README.md", "docs/a.txt")
44+
45+ // A private repository, pushed by root.
46+ wt := h.workTree("main", "secret.txt")
47+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
48+
49+ dir := t.TempDir()
50+ out, err := h.git(dir, "clone", "-q", h.url("/pub.git"), "pub")
51+ require.NoError(t, err, out)
52+ assert.FileExists(t, filepath.Join(dir, "pub", "docs", "a.txt"))
53+ // The .git suffix is optional.
54+ out, err = h.git(dir, "clone", "-q", h.url("/pub"), "pub2")
55+ require.NoError(t, err, out)
56+
57+ // Private and missing look the same to an anonymous client: 401 + challenge.
58+ for _, name := range []string{"priv", "nothing-here"} {
59+ req, _ := http.NewRequest(http.MethodGet, h.url("/"+name+".git/info/refs?service=git-upload-pack"), nil)
60+ resp, err := http.DefaultClient.Do(req)
61+ require.NoError(t, err)
62+ _ = resp.Body.Close()
63+ assert.Equal(t, 401, resp.StatusCode, name)
64+ assert.Equal(t, `Basic realm="ermite"`, resp.Header.Get("WWW-Authenticate"), name)
65+ }
66+ out, err = h.git(dir, "clone", "-q", h.url("/priv.git"), "nope")
67+ require.Error(t, err)
68+ assert.NotContains(t, out, "secret.txt")
69+ assert.NoDirExists(t, filepath.Join(dir, "nope"))
70+
71+ // A valid token without access gets "not found", the same as a missing repository.
72+ other := h.token(scopes(domain.ScopeRepoRead), "pub")
73+ out, err = h.git(dir, "clone", "-q", h.urlAuth(other, "/priv.git"), "nope")
74+ require.Error(t, err)
75+ assert.Contains(t, out, "not found")
76+ out2, err := h.git(dir, "clone", "-q", h.urlAuth(other, "/nothing-here.git"), "nope2")
77+ require.Error(t, err)
78+ assert.Contains(t, out2, "not found")
79+
80+ // A reader of the repository, and root, can clone it.
81+ reader := h.token(scopes(domain.ScopeRepoRead), "priv")
82+ for _, secret := range []string{reader, adminToken} {
83+ d := t.TempDir()
84+ out, err = h.git(d, "clone", "-q", h.urlAuth(secret, "/priv.git"), "c")
85+ require.NoError(t, err, out)
86+ assert.FileExists(t, filepath.Join(d, "c", "secret.txt"))
87+ }
88+ // A token with repo:read for every repository, passed as a Bearer header.
89+ all := h.token(scopes(domain.ScopeRepoRead))
90+ d := t.TempDir()
91+ out, err = h.git(d, "-c", "http.extraHeader=Authorization: Bearer "+all, "clone", "-q", h.url("/priv.git"), "c")
92+ require.NoError(t, err, out)
93+}
94+
95+// Only the smart HTTP routes exist; the rest of the repository tree is 404.
96+func TestRoutes(t *testing.T) {
97+ h := newHarness(t, harnessOpts{})
98+ h.publicRepo("pub", "a")
99+
100+ for _, path := range []string{
101+ "/pub.git/HEAD",
102+ "/pub.git/objects/info/packs",
103+ "/pub.git/info/refs", // dumb protocol: no service
104+ "/pub.git/info/refs?service=git-bogus",
105+ "/pub.git/config",
106+ "/pub/tree/main",
107+ "/",
108+ "/-/nothing",
109+ "/Bad.git/info/refs?service=git-upload-pack",
110+ "/..%2f..%2fetc/info/refs?service=git-upload-pack",
111+ } {
112+ assert.Equal(t, 404, h.status("GET", path, nil), path)
113+ }
114+ assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
115+ assert.Equal(t, 405, h.status("GET", "/pub.git/git-upload-pack", nil))
116+ assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
117+ assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
118+ // Anonymous receive-pack is a challenge even for a public repository.
119+ assert.Equal(t, 401, h.status("GET", "/pub.git/info/refs?service=git-receive-pack", nil))
120+ assert.Equal(t, 401, h.status("POST", "/pub.git/git-receive-pack", nil))
121+}
122+
123+// AC2: who may push.
124+func TestAC2PushAuthorization(t *testing.T) {
125+ h := newHarness(t, harnessOpts{})
126+ wt := h.workTree("main", "a.txt")
127+
128+ // Root creates the repository.
129+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
130+ require.NoError(t, err, out)
131+
132+ writer := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "proj")
133+ writerAll := h.token(scopes(domain.ScopeRepoWrite))
134+ reader := h.token(scopes(domain.ScopeRepoRead))
135+ elsewhere := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "other")
136+
137+ next := func(file string) {
138+ require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
139+ h.mustGit(wt, "add", file)
140+ h.mustGit(wt, "commit", "-q", "-m", file)
141+ }
142+
143+ for _, secret := range []string{writer, writerAll, adminToken} {
144+ next("w-" + secret[len(secret)-6:])
145+ out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/proj.git"), "main")
146+ require.NoError(t, err, out)
147+ }
148+
149+ // A reader is known but may not write: 403.
150+ next("by-reader")
151+ out, err = h.git(wt, "push", "-q", h.urlAuth(reader, "/proj.git"), "main")
152+ require.Error(t, err)
153+ assert.Contains(t, out, "403")
154+
155+ // A token limited to another repository cannot even see this private one.
156+ out, err = h.git(wt, "push", "-q", h.urlAuth(elsewhere, "/proj.git"), "main")
157+ require.Error(t, err)
158+ assert.Contains(t, out, "not found")
159+
160+ // ... and it may not create a repository outside its list.
161+ _, err = h.git(wt, "push", "-q", h.urlAuth(elsewhere, "/brand-new.git"), "main")
162+ require.Error(t, err)
163+ assert.NoDirExists(t, h.repoDir("brand-new"))
164+
165+ // Nothing from the refused pushes arrived.
166+ assert.NotContains(t, h.bareGit("proj", "log", "--format=%s", "main"), "by-reader")
167+
168+ // Garbage credentials: 401.
169+ out, err = h.git(wt, "push", "-q", h.urlAuth("ermite_not-a-token", "/proj.git"), "main")
170+ require.Error(t, err)
171+ assert.Contains(t, out, "Authentication failed")
172+
173+ // Expired token (written to the store directly: the use case refuses a past expiry).
174+ expired := "ermite_expired-secret-0123456789"
175+ require.NoError(t, h.forge.store.Tokens().Create(h.ctx, domain.Token{
176+ ID: "expired1", Label: "old", Hash: domain.HashTokenSecret(expired),
177+ Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-time.Hour),
178+ }))
179+ out, err = h.git(wt, "push", "-q", h.urlAuth(expired, "/proj.git"), "main")
180+ require.Error(t, err)
181+ assert.Contains(t, out, "Authentication failed")
182+
183+ // Revoked token.
184+ created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)})
185+ require.NoError(t, err)
186+ next("before-revoke")
187+ out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
188+ require.NoError(t, err, out)
189+ require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
190+ next("after-revoke")
191+ out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
192+ require.Error(t, err)
193+ assert.Contains(t, out, "Authentication failed")
194+ assert.NotContains(t, h.bareGit("proj", "log", "--format=%s", "main"), "after-revoke")
195+
196+ // Anonymous pushes are a plain 401.
197+ _, err = h.git(wt, "push", "-q", h.url("/proj.git"), "main")
198+ require.Error(t, err)
199+
200+ // The token store index stays consistent with revocation (AC8).
201+ _, err = h.forge.store.Tokens().FindByHash(h.ctx, domain.HashTokenSecret(created.Secret))
202+ assert.ErrorIs(t, err, domain.ErrNotFound)
203+ list, err := h.forge.Tokens.List(h.ctx, domain.Root())
204+ require.NoError(t, err)
205+ for _, tok := range list {
206+ _, err := h.forge.store.Tokens().FindByHash(h.ctx, tok.Hash)
207+ assert.NoError(t, err, tok.ID)
208+ }
209+}
210+
211+// AC3: first push creates a private repository with HEAD on the pushed branch.
212+func TestAC3CreateOnPush(t *testing.T) {
213+ h := newHarness(t, harnessOpts{})
214+ events := h.forge.Events.Subscribe(h.ctx)
215+
216+ // Only "dev" is pushed: HEAD (initially main) moves to it.
217+ wt := h.workTree("dev", "a")
218+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/only-dev.git"), "dev")
219+ require.NoError(t, err, out)
220+ assert.Equal(t, "refs/heads/dev", h.bareGit("only-dev", "symbolic-ref", "HEAD"))
221+
222+ repo, err := h.forge.Repos.Get(h.ctx, domain.Root(), h.name("only-dev"))
223+ require.NoError(t, err)
224+ assert.Equal(t, domain.VisibilityPrivate, repo.Visibility)
225+ assert.False(t, repo.PushedAt.IsZero(), "pushed_at is set by post-receive")
226+ _, err = h.forge.Repos.Get(h.ctx, domain.Anonymous(), h.name("only-dev"))
227+ assert.ErrorIs(t, err, domain.ErrNotFound, "private by default")
228+
229+ var created domain.RepoCreated
230+ select {
231+ case ev := <-events:
232+ created = ev.(domain.RepoCreated)
233+ case <-time.After(5 * time.Second):
234+ t.Fatal("no RepoCreated event")
235+ }
236+ assert.Equal(t, h.name("only-dev"), created.Repo)
237+ assert.Equal(t, domain.VisibilityPrivate, created.Visibility)
238+
239+ // Both main and dev are pushed: main is preferred.
240+ wt2 := h.workTree("dev", "a")
241+ h.mustGit(wt2, "branch", "main")
242+ out, err = h.git(wt2, "push", "-q", h.urlAuth(adminToken, "/both.git"), "dev", "main")
243+ require.NoError(t, err, out)
244+ assert.Equal(t, "refs/heads/main", h.bareGit("both", "symbolic-ref", "HEAD"))
245+
246+ // An existing valid HEAD is left alone by later pushes.
247+ h.mustGit(wt2, "checkout", "-q", "-b", "feature")
248+ out, err = h.git(wt2, "push", "-q", h.urlAuth(adminToken, "/both.git"), "feature")
249+ require.NoError(t, err, out)
250+ assert.Equal(t, "refs/heads/main", h.bareGit("both", "symbolic-ref", "HEAD"))
251+
252+ // An invalid name: 404 and nothing created, by a token that could create.
253+ before, err := h.forge.Repos.List(h.ctx, domain.Root())
254+ require.NoError(t, err)
255+ for _, bad := range []string{"/Upper.git", "/has%20space.git", "/-.git", "/robots.txt", "/a..b.git"} {
256+ assert.Equal(t, 404, h.statusAuth("GET", bad+"/info/refs?service=git-receive-pack", adminToken), bad)
257+ }
258+ after, err := h.forge.Repos.List(h.ctx, domain.Root())
259+ require.NoError(t, err)
260+ assert.Equal(t, before, after)
261+ entries, err := os.ReadDir(filepath.Join(h.data, "repos"))
262+ require.NoError(t, err)
263+ for _, e := range entries {
264+ assert.NotContains(t, strings.ToLower(e.Name()), "upper")
265+ }
266+
267+ // A fetch of an unknown repository never creates it, even for root.
268+ assert.Equal(t, 404, h.statusAuth("GET", "/ghost.git/info/refs?service=git-upload-pack", adminToken))
269+ assert.NoDirExists(t, h.repoDir("ghost"))
270+ // Neither does the second request of a push.
271+ assert.Equal(t, 404, h.statusAuth("POST", "/ghost.git/git-receive-pack", adminToken))
272+ assert.NoDirExists(t, h.repoDir("ghost"))
273+}
274+
275+// statusAuth is status with the token as Basic credentials.
276+func (h *harness) statusAuth(method, path, secret string) int {
277+ h.t.Helper()
278+ req, err := http.NewRequest(method, h.url(path), nil)
279+ require.NoError(h.t, err)
280+ req.SetBasicAuth("x", secret)
281+ resp, err := http.DefaultClient.Do(req)
282+ require.NoError(h.t, err)
283+ _ = resp.Body.Close()
284+ return resp.StatusCode
285+}
286+
287+// AC4: hooks fail closed, and post-receive records exactly what was pushed.
288+func TestAC4HooksFailClosed(t *testing.T) {
289+ h := newHarness(t, harnessOpts{})
290+ wt := h.workTree("main", "a")
291+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
292+ require.NoError(t, err, out)
293+ before := h.bareGit("proj", "rev-parse", "main")
294+
295+ // The hook socket goes away: pre-receive cannot ask ermite and rejects.
296+ h.forge.hooks.Close()
297+ select {
298+ case <-h.hooksDone:
299+ case <-time.After(5 * time.Second):
300+ t.Fatal("hook server did not stop")
301+ }
302+ require.NoError(t, os.WriteFile(filepath.Join(wt, "b"), []byte("b"), 0o644))
303+ h.mustGit(wt, "add", "b")
304+ h.mustGit(wt, "commit", "-q", "-m", "b")
305+ out, err = h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
306+ require.Error(t, err)
307+ assert.Contains(t, out, "unreachable")
308+ assert.Contains(t, out, "rejected")
309+ assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"), "the ref did not move")
310+}
311+
312+func TestAC4HooksUnknownTokenAndMissingBinary(t *testing.T) {
313+ // The hook scripts point at a binary that does not exist: git cannot run
314+ // the hook, so the push fails (it never succeeds unchecked).
315+ h := newHarness(t, harnessOpts{hookExe: "/nonexistent/ermite"})
316+ wt := h.workTree("main", "a")
317+ out, err := h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
318+ require.Error(t, err, out)
319+ _, gerr := os.Stat(h.repoDir("proj"))
320+ require.NoError(t, gerr, "the repository was created by the first request")
321+ assert.Empty(t, h.bareGit("proj", "for-each-ref"), "nothing was accepted")
322+}
323+
324+func TestAC4PostReceiveAttribution(t *testing.T) {
325+ h := newHarness(t, harnessOpts{})
326+ events := h.forge.Events.Subscribe(h.ctx)
327+
328+ // Two repositories, pushed at the same time with different refs.
329+ const n = 4
330+ type job struct {
331+ repo, branch string
332+ wt string
333+ }
334+ var jobs []job
335+ for i := range n {
336+ repo := "repo" + string(rune('a'+i))
337+ branch := "branch-" + string(rune('a'+i))
338+ jobs = append(jobs, job{repo: repo, branch: branch, wt: h.workTree(branch, "f-"+repo)})
339+ }
340+ var wg sync.WaitGroup
341+ for _, j := range jobs {
342+ wg.Add(1)
343+ go func() {
344+ defer wg.Done()
345+ out, err := h.git(j.wt, "push", "-q", h.urlAuth(adminToken, "/"+j.repo+".git"), j.branch)
346+ assert.NoError(t, err, out)
347+ }()
348+ }
349+ wg.Wait()
350+
351+ got := map[domain.RepoName]domain.PushEvent{}
352+ deadline := time.After(10 * time.Second)
353+ for len(got) < n {
354+ select {
355+ case ev := <-events:
356+ if pe, ok := ev.(domain.PushEvent); ok {
357+ require.NotContains(t, got, pe.Repo, "one event per push")
358+ got[pe.Repo] = pe
359+ }
360+ case <-deadline:
361+ t.Fatalf("got %d push events, want %d", len(got), n)
362+ }
363+ }
364+ for _, j := range jobs {
365+ pe := got[h.name(j.repo)]
366+ require.Len(t, pe.Updates, 1, j.repo)
367+ assert.Equal(t, "refs/heads/"+j.branch, pe.Updates[0].Ref, j.repo)
368+ assert.Equal(t, domain.ZeroOID, pe.Updates[0].Old)
369+ assert.Equal(t, h.bareGit(j.repo, "rev-parse", j.branch), pe.Updates[0].New, "the exact new object id")
370+ repo, err := h.forge.Repos.Get(h.ctx, domain.Root(), h.name(j.repo))
371+ require.NoError(t, err)
372+ assert.False(t, repo.PushedAt.IsZero())
373+ }
374+
375+ // A second push reports the old id, a deletion reports the zero id.
376+ wt := jobs[0].wt
377+ old := h.bareGit(jobs[0].repo, "rev-parse", jobs[0].branch)
378+ require.NoError(t, os.WriteFile(filepath.Join(wt, "again"), []byte("x"), 0o644))
379+ h.mustGit(wt, "add", "again")
380+ h.mustGit(wt, "commit", "-q", "-m", "again")
381+ h.mustGit(wt, "tag", "v1")
382+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/"+jobs[0].repo+".git"), jobs[0].branch, "v1")
383+ require.NoError(t, err, out)
384+ pe := waitPush(t, events)
385+ require.Len(t, pe.Updates, 2)
386+ byRef := map[string]domain.RefUpdate{}
387+ for _, u := range pe.Updates {
388+ byRef[u.Ref] = u
389+ }
390+ assert.Equal(t, old, byRef["refs/heads/"+jobs[0].branch].Old)
391+ assert.True(t, byRef["refs/tags/v1"].IsCreate())
392+
393+ out, err = h.git(wt, "push", "-q", h.urlAuth(adminToken, "/"+jobs[0].repo+".git"), ":refs/tags/v1")
394+ require.NoError(t, err, out)
395+ pe = waitPush(t, events)
396+ require.Len(t, pe.Updates, 1)
397+ assert.True(t, pe.Updates[0].IsDelete())
398+}
399+
400+func waitPush(t *testing.T, events <-chan domain.Event) domain.PushEvent {
401+ t.Helper()
402+ deadline := time.After(10 * time.Second)
403+ for {
404+ select {
405+ case ev := <-events:
406+ if pe, ok := ev.(domain.PushEvent); ok {
407+ return pe
408+ }
409+ case <-deadline:
410+ t.Fatal("no PushEvent")
411+ }
412+ }
413+}
414+
415+// AC5: the failed-authentication limiter, with and without trusted proxies.
416+func TestAC5Limiter(t *testing.T) {
417+ t.Run("direct clients", func(t *testing.T) {
418+ h := newHarness(t, harnessOpts{})
419+ bad := func(hdr http.Header) int {
420+ req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
421+ req.SetBasicAuth("u", "ermite_wrong")
422+ for k, v := range hdr {
423+ req.Header[k] = v
424+ }
425+ resp, err := http.DefaultClient.Do(req)
426+ require.NoError(t, err)
427+ _ = resp.Body.Close()
428+ return resp.StatusCode
429+ }
430+ for i := range 10 {
431+ require.Equal(t, 401, bad(nil), "failure %d", i+1)
432+ }
433+ assert.Equal(t, 429, bad(nil), "the 11th request is blocked")
434+ // Even good credentials are blocked while the address is.
435+ assert.Equal(t, 429, h.statusAuth("GET", "/x.git/info/refs?service=git-upload-pack", adminToken))
436+ assert.Equal(t, 429, h.status("GET", "/x.git/info/refs?service=git-upload-pack", nil), "anonymous too")
437+ // X-Forwarded-For from an untrusted peer is ignored.
438+ assert.Equal(t, 429, bad(http.Header{"X-Forwarded-For": {"198.51.100.9"}}))
439+ // Non-Git routes are not limited.
440+ assert.Equal(t, 200, h.status("GET", "/-/health", nil))
441+
442+ resp, err := http.Get(h.url("/x.git/info/refs?service=git-upload-pack"))
443+ require.NoError(t, err)
444+ _ = resp.Body.Close()
445+ assert.NotEmpty(t, resp.Header.Get("Retry-After"))
446+ })
447+
448+ t.Run("trusted proxy", func(t *testing.T) {
449+ h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
450+ bad := func(xff string) int {
451+ req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
452+ req.SetBasicAuth("u", "ermite_wrong")
453+ req.Header.Set("X-Forwarded-For", xff)
454+ resp, err := http.DefaultClient.Do(req)
455+ require.NoError(t, err)
456+ _ = resp.Body.Close()
457+ return resp.StatusCode
458+ }
459+ // A spoofed left entry does not help: the right-most untrusted one counts.
460+ for i := range 10 {
461+ require.Equal(t, 401, bad("1.1.1."+string(rune('0'+i))+", 198.51.100.7"), "failure %d", i+1)
462+ }
463+ assert.Equal(t, 429, bad("2.2.2.2, 198.51.100.7"), "client 198.51.100.7 is blocked")
464+ assert.Equal(t, 401, bad("198.51.100.8"), "another client behind the proxy is not")
465+ req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
466+ req.Header.Set("X-Forwarded-For", "198.51.100.7")
467+ resp, err := http.DefaultClient.Do(req)
468+ require.NoError(t, err)
469+ _ = resp.Body.Close()
470+ assert.Equal(t, 429, resp.StatusCode)
471+ })
472+
473+ t.Run("successes do not count", func(t *testing.T) {
474+ h := newHarness(t, harnessOpts{})
475+ for range 25 {
476+ assert.Equal(t, 404, h.statusAuth("GET", "/x.git/info/refs?service=git-upload-pack", adminToken))
477+ }
478+ // Anonymous requests for a private/missing repo are challenges, not failures.
479+ for range 25 {
480+ assert.Equal(t, 401, h.status("GET", "/x.git/info/refs?service=git-upload-pack", nil))
481+ }
482+ })
483+}
484+
485+// AC6: protocol v2 and partial clone.
486+func TestAC6ProtocolV2AndPartialClone(t *testing.T) {
487+ h := newHarness(t, harnessOpts{})
488+ h.publicRepo("pub", "README.md", "src/main.go", "src/util.go")
489+
490+ dir := t.TempDir()
491+ out, err := h.gitEnv([]string{"GIT_TRACE_PACKET=1"}, dir, "-c", "protocol.version=2", "clone", "-q", h.url("/pub.git"), "v2")
492+ require.NoError(t, err, out)
493+ assert.Contains(t, out, "version 2", "the server speaks protocol v2")
494+ assert.Contains(t, out, "ls-refs")
495+
496+ // Protocol v0 still works for old clients.
497+ out, err = h.git(dir, "-c", "protocol.version=0", "clone", "-q", h.url("/pub.git"), "v0")
498+ require.NoError(t, err, out)
499+
500+ // A later fetch uses v2 as well.
501+ wt := h.workTree("main", "x")
502+ h.mustGit(wt, "push", "-q", "-f", h.urlAuth(adminToken, "/pub.git"), "main")
503+ out, err = h.gitEnv([]string{"GIT_TRACE_PACKET=1"}, filepath.Join(dir, "v2"), "-c", "protocol.version=2", "fetch", "-q")
504+ require.NoError(t, err, out)
505+ assert.Contains(t, out, "fetch=")
506+
507+ // Partial clone: no blobs come down until they are needed.
508+ out, err = h.git(dir, "-c", "protocol.version=2", "clone", "-q", "--no-checkout", "--filter=blob:none", h.url("/pub.git"), "partial")
509+ require.NoError(t, err, out)
510+ partial := filepath.Join(dir, "partial")
511+ assert.Equal(t, "blob:none", strings.TrimSpace(h.mustGit(partial, "config", "remote.origin.partialclonefilter")))
512+ missing := h.mustGit(partial, "rev-list", "--objects", "--missing=print", "--all")
513+ assert.Contains(t, missing, "?", "blobs are missing after the clone")
514+ // ... and are fetched on demand (the promisor remote is the same URL).
515+ out, err = h.git(partial, "checkout", "-q", "main")
516+ require.NoError(t, err, out)
517+ assert.FileExists(t, filepath.Join(partial, "x"))
518+}
519+
520+// AC7: tokens never appear in logs, nor in the environment of the hooks.
521+func TestAC7NoSecretsInLogsOrHookEnvironment(t *testing.T) {
522+ envDump := filepath.Join(t.TempDir(), "hookenv.txt")
523+ bin := binary(t)
524+ // A wrapper that records the environment of the hook, then runs the real hook.
525+ wrapper := filepath.Join(t.TempDir(), "ermite-wrapper")
526+ require.NoError(t, os.WriteFile(wrapper, []byte("#!/bin/sh\nenv >> '"+envDump+"'\nexec '"+bin+"' \"$@\"\n"), 0o755))
527+ h := newHarness(t, harnessOpts{hookExe: wrapper})
528+
529+ stored := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite))
530+ wt := h.workTree("main", "a")
531+ for _, secret := range []string{stored, adminToken} {
532+ out, err := h.git(wt, "push", "-q", "-f", h.urlAuth(secret, "/proj.git"), "main")
533+ require.NoError(t, err, out)
534+ }
535+ // Bad credentials, a Bearer header, and a clone as well.
536+ _, _ = h.git(wt, "push", "-q", h.urlAuth("ermite_secret-that-is-wrong", "/proj.git"), "main")
537+ out, err := h.git(wt, "-c", "http.extraHeader=Authorization: Bearer "+stored, "push", "-q", "-f", h.url("/proj.git"), "main")
538+ require.NoError(t, err, out)
539+ out, err = h.git(t.TempDir(), "clone", "-q", h.urlAuth(stored, "/proj.git"), "c")
540+ require.NoError(t, err, out)
541+
542+ logs := h.logs.String()
543+ require.Contains(t, logs, "push received", "the logs of the pushes were captured")
544+ secrets := []string{stored, adminToken, "ermite_secret-that-is-wrong"}
545+ for _, s := range secrets {
546+ basic := base64.StdEncoding.EncodeToString([]byte("user:" + s))
547+ assert.NotContains(t, logs, s)
548+ assert.NotContains(t, logs, basic)
549+ assert.NotContains(t, logs, base64.StdEncoding.EncodeToString([]byte(s)))
550+ }
551+ assert.NotContains(t, strings.ToLower(logs), "authorization")
552+ assert.NotContains(t, logs, "Basic ")
553+ assert.NotContains(t, logs, "Bearer ")
554+
555+ dumped, err := os.ReadFile(envDump)
556+ require.NoError(t, err)
557+ require.Contains(t, string(dumped), "ERMITE_HOOK_TOKEN=", "the hooks ran with their token")
558+ assert.Contains(t, string(dumped), "ERMITE_HOOK_SOCKET=")
559+ for _, s := range secrets {
560+ assert.NotContains(t, string(dumped), s, "the hook environment")
561+ }
562+ assert.NotContains(t, strings.ToUpper(string(dumped)), "HTTP_AUTHORIZATION")
563+ assert.NotContains(t, strings.ToUpper(string(dumped)), "HTTP_COOKIE")
564+}
565+
566+// AC8, through the wired use cases: no escalation and a consistent index.
567+func TestAC8TokenCreationThroughTheForge(t *testing.T) {
568+ h := newHarness(t, harnessOpts{})
569+ ctx := context.Background()
570+
571+ admin, err := h.forge.Tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
572+ Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")},
573+ })
574+ require.NoError(t, err)
575+ p, err := h.forge.Auth.Authenticate(ctx, admin.Secret)
576+ require.NoError(t, err)
577+
578+ _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}})
579+ assert.ErrorIs(t, err, domain.ErrForbidden, "scope escalation")
580+ _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
581+ assert.ErrorIs(t, err, domain.ErrForbidden, "repository list escalation")
582+ _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}})
583+ assert.ErrorIs(t, err, domain.ErrForbidden, "another repository")
584+ child, err := h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}})
585+ require.NoError(t, err)
586+
587+ // The child works on its repository, and revoking the index entry kills it at once.
588+ wt := h.workTree("main", "f")
589+ out, err := h.git(wt, "push", "-q", h.urlAuth(child.Secret, "/a.git"), "main")
590+ require.NoError(t, err, out)
591+ require.NoError(t, h.forge.Tokens.Revoke(ctx, p, child.Token.ID))
592+ out, err = h.git(wt, "push", "-q", h.urlAuth(child.Secret, "/a.git"), "main")
593+ require.Error(t, err, out)
594+
595+ list, err := h.forge.Tokens.List(ctx, domain.Root())
596+ require.NoError(t, err)
597+ require.Len(t, list, 1)
598+ assert.Equal(t, admin.Token.ID, list[0].ID)
599+ _, err = h.forge.store.Tokens().FindByHash(ctx, child.Token.Hash)
600+ assert.ErrorIs(t, err, domain.ErrNotFound)
601+ var _ ports.TokenStore = h.forge.store.Tokens()
602+}
603+
604+// Large pushes arrive chunked (git's http.postBuffer is 1 MiB): the handler
605+// spools them. Hooks, a path with quotes in the hook executable, and the
606+// debounced maintenance are exercised on the way.
607+func TestLargePushHookPathQuotingAndMaintenance(t *testing.T) {
608+ dir := filepath.Join(t.TempDir(), "it's a \"dir\" with spaces")
609+ require.NoError(t, os.MkdirAll(dir, 0o755))
610+ exe := filepath.Join(dir, "ermite")
611+ raw, err := os.ReadFile(binary(t))
612+ require.NoError(t, err)
613+ require.NoError(t, os.WriteFile(exe, raw, 0o755))
614+
615+ h := newHarness(t, harnessOpts{hookExe: exe, maintenance: 50 * time.Millisecond})
616+ wt := h.workTree("main", "a")
617+ big := make([]byte, 3<<20)
618+ _, err = rand.Read(big)
619+ require.NoError(t, err)
620+ require.NoError(t, os.WriteFile(filepath.Join(wt, "big.bin"), big, 0o644))
621+ h.mustGit(wt, "add", "big.bin")
622+ h.mustGit(wt, "commit", "-q", "-m", "big")
623+
624+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/big.git"), "main")
625+ require.NoError(t, err, out)
626+ assert.Equal(t, h.mustGit(wt, "rev-parse", "main"), h.mustGit(h.repoDir("big"), "rev-parse", "main"))
627+
628+ // Maintenance wrote a commit-graph after the debounce delay.
629+ chain := filepath.Join(h.repoDir("big"), "objects", "info", "commit-graphs", "commit-graph-chain")
630+ require.Eventually(t, func() bool { _, err := os.Stat(chain); return err == nil }, 10*time.Second, 20*time.Millisecond)
631+
632+ // The clone gets the big blob back intact.
633+ d := t.TempDir()
634+ out, err = h.git(d, "clone", "-q", h.urlAuth(adminToken, "/big.git"), "c")
635+ require.NoError(t, err, out)
636+ got, err := os.ReadFile(filepath.Join(d, "c", "big.bin"))
637+ require.NoError(t, err)
638+ assert.Equal(t, big, got)
639+
640+ // Hook scripts: 0755, exec the binary quoted.
641+ for _, hook := range []string{"pre-receive", "post-receive"} {
642+ fi, err := os.Stat(filepath.Join(h.data, "run", "hooks", hook))
643+ require.NoError(t, err)
644+ assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
645+ }
646+ // Nothing was written into the repository itself.
647+ entries, err := os.ReadDir(filepath.Join(h.repoDir("big"), "hooks"))
648+ require.NoError(t, err)
649+ for _, e := range entries {
650+ assert.True(t, strings.HasSuffix(e.Name(), ".sample"), "hooks dir is untouched: %s", e.Name())
651+ }
652+ conf, err := os.ReadFile(filepath.Join(h.repoDir("big"), "config"))
653+ require.NoError(t, err)
654+ assert.NotContains(t, string(conf), "hooksPath")
655+ assert.NotContains(t, string(conf), "receivepack")
656+}
657+
658+// receive.fsckObjects is on: a push with a malformed object is refused, and
659+// nothing is stored in the repository's own configuration to get there.
660+func TestFsckRejectsMalformedObjects(t *testing.T) {
661+ h := newHarness(t, harnessOpts{})
662+ wt := h.workTree("main", "a")
663+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
664+ require.NoError(t, err, out)
665+ before := h.bareGit("proj", "rev-parse", "main")
666+
667+ // A tree whose entries are not sorted: "b" before "a".
668+ blob := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD:a"))
669+ raw := make([]byte, 0, 64)
670+ for _, name := range []string{"b", "a"} {
671+ raw = append(raw, []byte("100644 "+name+"\x00")...)
672+ oid, err := hex.DecodeString(blob)
673+ require.NoError(t, err)
674+ raw = append(raw, oid...)
675+ }
676+ treeFile := filepath.Join(t.TempDir(), "tree")
677+ require.NoError(t, os.WriteFile(treeFile, raw, 0o644))
678+ tree := strings.TrimSpace(h.mustGit(wt, "hash-object", "-w", "-t", "tree", "--literally", treeFile))
679+ commit := strings.TrimSpace(h.mustGit(wt, "commit-tree", "-p", "HEAD", "-m", "malformed", tree))
680+ h.mustGit(wt, "update-ref", "refs/heads/bad", commit)
681+
682+ out, err = h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "bad")
683+ require.Error(t, err, out)
684+ assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"))
685+ assert.Empty(t, h.bareGit("proj", "for-each-ref", "refs/heads/bad"), "the ref was not created")
686+ conf, err := os.ReadFile(filepath.Join(h.repoDir("proj"), "config"))
687+ require.NoError(t, err)
688+ assert.NotContains(t, string(conf), "fsck")
689+}
690+
691+// The built binary end to end: `ermite serve` as a process, its hook scripts
692+// exec'ing the same binary, and a real git client pushing and cloning.
693+func TestBuiltBinaryPushAndClone(t *testing.T) {
694+ gitBin, err := exec.LookPath("git")
695+ if err != nil {
696+ t.Skip("git not found")
697+ }
698+ data, err := os.MkdirTemp("", "ermite")
699+ require.NoError(t, err)
700+ t.Cleanup(func() { _ = os.RemoveAll(data) })
701+ addr := freeAddr(t)
702+
703+ cmd := exec.Command(binary(t), "serve")
704+ cmd.Env = append(baseEnv(),
705+ "ERMITE_ADDR="+addr,
706+ "ERMITE_DATA_DIR="+data,
707+ "ERMITE_PUBLIC_URL=http://"+addr,
708+ "ERMITE_ADMIN_TOKEN="+adminToken,
709+ "ERMITE_GIT="+gitBin,
710+ "ERMITE_LOG_LEVEL=debug",
711+ )
712+ var logs syncBuffer
713+ cmd.Stdout, cmd.Stderr = &logs, &logs
714+ require.NoError(t, cmd.Start())
715+ exited := make(chan error, 1)
716+ go func() { exited <- cmd.Wait() }()
717+ t.Cleanup(func() { _ = cmd.Process.Kill() })
718+
719+ require.Eventually(t, func() bool {
720+ resp, err := http.Get("http://" + addr + "/-/health")
721+ if err != nil {
722+ return false
723+ }
724+ _ = resp.Body.Close()
725+ return resp.StatusCode == 200
726+ }, 15*time.Second, 50*time.Millisecond, logs.String())
727+
728+ home := t.TempDir()
729+ run := func(dir string, args ...string) string {
730+ c := exec.Command("git", args...)
731+ c.Dir = dir
732+ c.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
733+ "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
734+ out, err := c.CombinedOutput()
735+ require.NoError(t, err, "git %v\n%s\nserver logs:\n%s", args, out, logs.String())
736+ return string(out)
737+ }
738+ wt := t.TempDir()
739+ run(wt, "init", "-q", "--initial-branch=main")
740+ require.NoError(t, os.WriteFile(filepath.Join(wt, "hello.txt"), []byte("hello\n"), 0o644))
741+ run(wt, "add", ".")
742+ run(wt, "commit", "-q", "-m", "hello")
743+ url := "http://u:" + adminToken + "@" + addr + "/bin-test.git"
744+ run(wt, "push", "-q", url, "main")
745+ cl := t.TempDir()
746+ run(cl, "clone", "-q", url, "c")
747+ assert.FileExists(t, filepath.Join(cl, "c", "hello.txt"))
748+
749+ // The hooks ran: post-receive recorded the push.
750+ require.Eventually(t, func() bool { return strings.Contains(logs.String(), `"message":"push received"`) }, 5*time.Second, 20*time.Millisecond, logs.String())
751+ assert.NotContains(t, logs.String(), adminToken)
752+
753+ // Hook scripts exec this very binary.
754+ script, err := os.ReadFile(filepath.Join(data, "run", "hooks", "pre-receive"))
755+ require.NoError(t, err)
756+ assert.Contains(t, string(script), binary(t))
757+
758+ require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
759+ select {
760+ case err := <-exited:
761+ require.NoError(t, err, logs.String())
762+ case <-time.After(15 * time.Second):
763+ t.Fatalf("no clean stop\n%s", logs.String())
764+ }
765+ assert.NoFileExists(t, filepath.Join(data, "run", "hook.sock"), "the socket is removed on stop")
766+}
767+
768+// `ermite hook` is hidden, fails closed for pre-receive and never fails the
769+// push for post-receive.
770+func TestHookCommand(t *testing.T) {
771+ bin := binary(t)
772+ input := domainZero + " " + strings.Repeat("a", 40) + " refs/heads/main\n"
773+
774+ run := func(hook string, env ...string) (string, error) {
775+ c := exec.Command(bin, "hook", hook)
776+ c.Env = append(baseEnv(), env...)
777+ c.Stdin = strings.NewReader(input)
778+ out, err := c.CombinedOutput()
779+ return string(out), err
780+ }
781+ out, err := run("pre-receive")
782+ require.Error(t, err)
783+ assert.Contains(t, out, "ERMITE_HOOK_SOCKET")
784+ out, err = run("pre-receive", "ERMITE_HOOK_SOCKET=/nonexistent.sock", "ERMITE_HOOK_TOKEN=x")
785+ require.Error(t, err)
786+ assert.Contains(t, out, "unreachable")
787+ out, err = run("post-receive", "ERMITE_HOOK_SOCKET=/nonexistent.sock", "ERMITE_HOOK_TOKEN=x")
788+ require.NoError(t, err)
789+ assert.Contains(t, out, "post-receive")
790+
791+ help, err := exec.Command(bin, "--help").CombinedOutput()
792+ require.NoError(t, err)
793+ assert.NotContains(t, string(help), "hook", "the command is hidden")
794+}
795+
796+const domainZero = "0000000000000000000000000000000000000000"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close