Go · 15513 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package githttp_test
4
5 import (
6 "context"
7 "io"
8 "log/slog"
9 "net/http/httptest"
10 "net/url"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/go-chi/chi/v5"
19 "github.com/jackc/pgx/v5/pgtype"
20 "github.com/jackc/pgx/v5/pgxpool"
21
22 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
23 "github.com/tenseleyFlow/shithub/internal/auth/audit"
24 "github.com/tenseleyFlow/shithub/internal/auth/pat"
25 "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
26 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
27 "github.com/tenseleyFlow/shithub/internal/infra/storage"
28 "github.com/tenseleyFlow/shithub/internal/orgs"
29 "github.com/tenseleyFlow/shithub/internal/repos"
30 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
31 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
32 githttph "github.com/tenseleyFlow/shithub/internal/web/handlers/githttp"
33 )
34
35 // fixtureHash is a static PHC test fixture (zero salt, zero key) — not a credential.
36 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
37 "AAAAAAAAAAAAAAAA$" +
38 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
39
40 // gitCmd wraps exec.Command — test paths are all t.TempDir.
41 func gitCmd(args ...string) *exec.Cmd {
42 //nolint:gosec // G204: test fixture, t.TempDir paths.
43 return exec.Command("git", args...)
44 }
45
46 // env wires a verified user + a public repo (with init commit) + a
47 // private repo, mounts the smart-HTTP handlers on a httptest server,
48 // and returns everything callers need.
49 type env struct {
50 srv *httptest.Server
51 pool *pgxpool.Pool
52 userID int64
53 user string
54 pwd string
55 patRaw string
56 pubRepo string
57 pubRepoID int64
58 privRepo string
59 privRepoID int64
60 root string
61 runnerJWT *runnerjwt.Signer
62 }
63
64 func setupEnv(t *testing.T) *env {
65 t.Helper()
66 pool := dbtest.NewTestDB(t)
67 root := t.TempDir()
68 rfs, err := storage.NewRepoFS(root)
69 if err != nil {
70 t.Fatalf("NewRepoFS: %v", err)
71 }
72
73 uq := usersdb.New()
74 user, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
75 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
76 })
77 if err != nil {
78 t.Fatalf("CreateUser: %v", err)
79 }
80 em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
81 UserID: user.ID, Email: "alice@example.com", IsPrimary: true, Verified: true,
82 })
83 if err != nil {
84 t.Fatalf("CreateUserEmail: %v", err)
85 }
86 if err := uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
87 ID: user.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
88 }); err != nil {
89 t.Fatalf("LinkUserPrimaryEmail: %v", err)
90 }
91
92 // Public repo with one initial commit (so info/refs returns refs).
93 rdeps := repos.Deps{
94 Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
95 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
96 }
97 publicRes, err := repos.Create(context.Background(), rdeps, repos.Params{
98 OwnerUserID: user.ID, OwnerUsername: user.Username,
99 Name: "public-repo", Visibility: "public", InitReadme: true,
100 })
101 if err != nil {
102 t.Fatalf("create public: %v", err)
103 }
104 privateRes, err := repos.Create(context.Background(), rdeps, repos.Params{
105 OwnerUserID: user.ID, OwnerUsername: user.Username,
106 Name: "private-repo", Visibility: "private", InitReadme: true,
107 })
108 if err != nil {
109 t.Fatalf("create private: %v", err)
110 }
111
112 // Mint a PAT for alice.
113 raw, hash, prefix, err := pat.Mint()
114 if err != nil {
115 t.Fatalf("mint PAT: %v", err)
116 }
117 expires := pgtype.Timestamptz{Time: time.Now().Add(24 * time.Hour), Valid: true}
118 if _, err := uq.InsertUserToken(context.Background(), pool, usersdb.InsertUserTokenParams{
119 UserID: user.ID,
120 Name: "test",
121 TokenHash: hash,
122 TokenPrefix: prefix,
123 ExpiresAt: expires,
124 Scopes: []string{"repo"},
125 }); err != nil {
126 t.Fatalf("create PAT row: %v", err)
127 }
128
129 runnerJWT := runnerHTTPSigner(t)
130 h, err := githttph.New(githttph.Deps{
131 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
132 Pool: pool, RepoFS: rfs, RunnerJWT: runnerJWT,
133 })
134 if err != nil {
135 t.Fatalf("New: %v", err)
136 }
137 r := chi.NewRouter()
138 h.MountSmartHTTP(r)
139 srv := httptest.NewServer(r)
140 t.Cleanup(srv.Close)
141
142 return &env{
143 srv: srv, pool: pool, userID: user.ID,
144 user: "alice", pwd: "wrong-not-real-password", patRaw: raw,
145 pubRepo: "public-repo", pubRepoID: publicRes.Repo.ID,
146 privRepo: "private-repo", privRepoID: privateRes.Repo.ID,
147 root: root, runnerJWT: runnerJWT,
148 }
149 }
150
151 // authedURL embeds Basic credentials in the URL. git's libcurl honors
152 // the userinfo prefix for HTTP Basic.
153 func authedURL(srvURL, user, secret, repoPath string) string {
154 u, _ := url.Parse(srvURL)
155 u.User = url.UserPassword(user, secret)
156 u.Path = repoPath
157 return u.String()
158 }
159
160 func TestGitHTTP_AnonClonePublic(t *testing.T) {
161 t.Parallel()
162 env := setupEnv(t)
163 dst := filepath.Join(t.TempDir(), "clone")
164 out, err := gitCmd("clone", env.srv.URL+"/alice/public-repo.git", dst).CombinedOutput()
165 if err != nil {
166 t.Fatalf("clone: %v\n%s", err, out)
167 }
168 // HEAD must resolve to the initial commit's tree.
169 out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
170 if err != nil {
171 t.Fatalf("rev-list: %v\n%s", err, out)
172 }
173 if got := strings.TrimSpace(string(out)); got != "1" {
174 t.Fatalf("rev-list = %q, want 1", got)
175 }
176 }
177
178 func TestGitHTTP_AnonClonePrivateFails(t *testing.T) {
179 t.Parallel()
180 env := setupEnv(t)
181 dst := filepath.Join(t.TempDir(), "clone")
182 cmd := gitCmd("clone", env.srv.URL+"/alice/private-repo.git", dst)
183 // Suppress git's interactive credential prompt during the test.
184 cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false")
185 out, err := cmd.CombinedOutput()
186 if err == nil {
187 t.Fatalf("expected clone to fail; output: %s", out)
188 }
189 }
190
191 func TestGitHTTP_PATClonePrivate(t *testing.T) {
192 t.Parallel()
193 env := setupEnv(t)
194 dst := filepath.Join(t.TempDir(), "clone")
195 cloneURL := authedURL(env.srv.URL, env.user, env.patRaw, "/alice/private-repo.git")
196 out, err := gitCmd("clone", cloneURL, dst).CombinedOutput()
197 if err != nil {
198 t.Fatalf("clone: %v\n%s", err, out)
199 }
200 out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
201 if err != nil {
202 t.Fatalf("rev-list: %v\n%s", err, out)
203 }
204 if got := strings.TrimSpace(string(out)); got != "1" {
205 t.Fatalf("rev-list = %q, want 1", got)
206 }
207 }
208
209 func TestGitHTTP_RunnerCheckoutTokenClonesPrivateRepoReadOnly(t *testing.T) {
210 t.Parallel()
211 env := setupEnv(t)
212 token := env.runnerCheckoutToken(t, env.privRepoID)
213
214 dst := filepath.Join(t.TempDir(), "clone")
215 cloneURL := authedURL(env.srv.URL, "shithub-actions", token, "/alice/private-repo.git")
216 out, err := gitCmd("clone", cloneURL, dst).CombinedOutput()
217 if err != nil {
218 t.Fatalf("clone with checkout token: %v\n%s", err, out)
219 }
220 out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
221 if err != nil {
222 t.Fatalf("rev-list: %v\n%s", err, out)
223 }
224 if got := strings.TrimSpace(string(out)); got != "1" {
225 t.Fatalf("rev-list = %q, want 1", got)
226 }
227
228 for _, c := range [][]string{
229 {"-C", dst, "config", "user.name", "Runner"},
230 {"-C", dst, "config", "user.email", "runner@example.test"},
231 } {
232 if out, err := gitCmd(c...).CombinedOutput(); err != nil {
233 t.Fatalf("config: %v\n%s", err, out)
234 }
235 }
236 if err := writeFile(filepath.Join(dst, "blocked.txt"), "x\n"); err != nil {
237 t.Fatalf("write: %v", err)
238 }
239 for _, c := range [][]string{
240 {"-C", dst, "add", "blocked.txt"},
241 {"-C", dst, "commit", "-m", "blocked"},
242 } {
243 if out, err := gitCmd(c...).CombinedOutput(); err != nil {
244 t.Fatalf("git %v: %v\n%s", c, err, out)
245 }
246 }
247 out, err = gitCmd("-C", dst, "push", "origin", "trunk").CombinedOutput()
248 if err == nil {
249 t.Fatalf("checkout token push unexpectedly succeeded: %s", out)
250 }
251 if !strings.Contains(string(out), "read-only") {
252 t.Fatalf("expected read-only checkout-token message, got: %s", out)
253 }
254 }
255
256 func TestGitHTTP_RunnerCheckoutTokenCannotReadOtherRepo(t *testing.T) {
257 t.Parallel()
258 env := setupEnv(t)
259 token := env.runnerCheckoutToken(t, env.pubRepoID)
260 dst := filepath.Join(t.TempDir(), "clone")
261 cmd := gitCmd("clone", authedURL(env.srv.URL, "shithub-actions", token, "/alice/private-repo.git"), dst)
262 cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false")
263 out, err := cmd.CombinedOutput()
264 if err == nil {
265 t.Fatalf("expected checkout token for public repo to fail against private repo; output: %s", out)
266 }
267 }
268
269 func TestGitHTTP_PATPushRoundtrip(t *testing.T) {
270 t.Parallel()
271 env := setupEnv(t)
272 dst := filepath.Join(t.TempDir(), "clone")
273 cloneURL := authedURL(env.srv.URL, env.user, env.patRaw, "/alice/private-repo.git")
274
275 // Clone.
276 if out, err := gitCmd("clone", cloneURL, dst).CombinedOutput(); err != nil {
277 t.Fatalf("clone: %v\n%s", err, out)
278 }
279
280 // Configure committer + make a new commit.
281 for _, c := range [][]string{
282 {"-C", dst, "config", "user.name", "Alice"},
283 {"-C", dst, "config", "user.email", "alice@example.com"},
284 } {
285 if out, err := gitCmd(c...).CombinedOutput(); err != nil {
286 t.Fatalf("config: %v\n%s", err, out)
287 }
288 }
289 newFile := filepath.Join(dst, "newfile.txt")
290 if err := writeFile(newFile, "hello\n"); err != nil {
291 t.Fatalf("write: %v", err)
292 }
293 for _, c := range [][]string{
294 {"-C", dst, "add", "newfile.txt"},
295 {"-C", dst, "commit", "-m", "add newfile"},
296 } {
297 if out, err := gitCmd(c...).CombinedOutput(); err != nil {
298 t.Fatalf("git %v: %v\n%s", c, err, out)
299 }
300 }
301
302 // Push.
303 out, err := gitCmd("-C", dst, "push", "origin", "trunk").CombinedOutput()
304 if err != nil {
305 t.Fatalf("push: %v\n%s", err, out)
306 }
307
308 // Re-clone to a fresh dir; the new commit must be visible.
309 dst2 := filepath.Join(t.TempDir(), "clone2")
310 if out, err := gitCmd("clone", cloneURL, dst2).CombinedOutput(); err != nil {
311 t.Fatalf("clone2: %v\n%s", err, out)
312 }
313 if out, err := gitCmd("-C", dst2, "log", "--oneline", "trunk").CombinedOutput(); err != nil {
314 t.Fatalf("log: %v\n%s", err, out)
315 } else if !strings.Contains(string(out), "add newfile") {
316 t.Fatalf("expected pushed commit in re-clone log; got: %s", out)
317 }
318 }
319
320 func TestGitHTTP_PushToArchivedRejected(t *testing.T) {
321 t.Parallel()
322 env := setupEnv(t)
323 // Archive the public repo.
324 if _, err := env.pool.Exec(context.Background(),
325 "UPDATE repos SET is_archived = true WHERE name = 'public-repo'"); err != nil {
326 t.Fatalf("archive: %v", err)
327 }
328
329 // Authed clone is fine (read still allowed).
330 dst := filepath.Join(t.TempDir(), "clone")
331 cloneURL := authedURL(env.srv.URL, env.user, env.patRaw, "/alice/public-repo.git")
332 if out, err := gitCmd("clone", cloneURL, dst).CombinedOutput(); err != nil {
333 t.Fatalf("clone: %v\n%s", err, out)
334 }
335
336 // Make a commit and try to push.
337 for _, c := range [][]string{
338 {"-C", dst, "config", "user.name", "Alice"},
339 {"-C", dst, "config", "user.email", "alice@example.com"},
340 } {
341 _, _ = gitCmd(c...).CombinedOutput()
342 }
343 _ = writeFile(filepath.Join(dst, "blocked.txt"), "x\n")
344 for _, c := range [][]string{
345 {"-C", dst, "add", "blocked.txt"},
346 {"-C", dst, "commit", "-m", "blocked"},
347 } {
348 _, _ = gitCmd(c...).CombinedOutput()
349 }
350
351 out, err := gitCmd("-C", dst, "push", "origin", "trunk").CombinedOutput()
352 if err == nil {
353 t.Fatalf("expected push to fail; output: %s", out)
354 }
355 if !strings.Contains(string(out), "archived") {
356 t.Fatalf("expected friendly archived-message in stderr, got: %s", out)
357 }
358 }
359
360 func writeFile(path, body string) error {
361 return os.WriteFile(path, []byte(body), 0o600)
362 }
363
364 func (e *env) runnerCheckoutToken(t *testing.T, repoID int64) string {
365 t.Helper()
366 ctx := context.Background()
367 q := actionsdb.New()
368 runner, err := q.InsertRunner(ctx, e.pool, actionsdb.InsertRunnerParams{
369 Name: "runner-" + e.privRepo,
370 Labels: []string{"ubuntu-latest"},
371 Capacity: 1,
372 })
373 if err != nil {
374 t.Fatalf("InsertRunner: %v", err)
375 }
376 run, err := q.InsertWorkflowRun(ctx, e.pool, actionsdb.InsertWorkflowRunParams{
377 RepoID: repoID,
378 RunIndex: 1,
379 WorkflowFile: ".shithub/workflows/ci.yml",
380 WorkflowName: "CI",
381 HeadSha: strings.Repeat("a", 40),
382 HeadRef: "refs/heads/trunk",
383 Event: actionsdb.WorkflowRunEventPush,
384 EventPayload: []byte(`{}`),
385 ActorUserID: pgtype.Int8{Int64: e.userID, Valid: true},
386 })
387 if err != nil {
388 t.Fatalf("InsertWorkflowRun: %v", err)
389 }
390 job, err := q.InsertWorkflowJob(ctx, e.pool, actionsdb.InsertWorkflowJobParams{
391 RunID: run.ID,
392 JobIndex: 0,
393 JobKey: "checkout",
394 JobName: "checkout",
395 RunsOn: "ubuntu-latest",
396 NeedsJobs: []string{},
397 TimeoutMinutes: 30,
398 Permissions: []byte(`{}`),
399 JobEnv: []byte(`{}`),
400 })
401 if err != nil {
402 t.Fatalf("InsertWorkflowJob: %v", err)
403 }
404 if _, err := e.pool.Exec(ctx, `UPDATE workflow_jobs SET runner_id = $1, status = 'running', started_at = now() WHERE id = $2`, runner.ID, job.ID); err != nil {
405 t.Fatalf("mark job running: %v", err)
406 }
407 token, _, err := e.runnerJWT.Mint(runnerjwt.MintParams{
408 RunnerID: runner.ID,
409 JobID: job.ID,
410 RunID: run.ID,
411 RepoID: repoID,
412 Purpose: runnerjwt.PurposeCheckout,
413 })
414 if err != nil {
415 t.Fatalf("Mint checkout token: %v", err)
416 }
417 return token
418 }
419
420 func runnerHTTPSigner(t *testing.T) *runnerjwt.Signer {
421 t.Helper()
422 signer, err := runnerjwt.NewFromKey(
423 bytesOf(0x88, 32),
424 runnerjwt.WithClock(func() time.Time { return time.Now().UTC() }),
425 )
426 if err != nil {
427 t.Fatalf("runnerjwt.NewFromKey: %v", err)
428 }
429 return signer
430 }
431
432 func bytesOf(b byte, n int) []byte {
433 out := make([]byte, n)
434 for i := range out {
435 out[i] = b
436 }
437 return out
438 }
439
440 // TestGitHTTP_AnonCloneOrgOwnedPublic is a regression for an outage
441 // on 2026-05-09: pushing to https://shithub.sh/tenseleyflow/shithub.git
442 // returned 404 because authorizeForService only resolved owner-slugs
443 // as users (the comment in handler.go admitted "orgs come in S31").
444 // Once that landed in production with an org-owned repo, the smart-
445 // HTTP route became silently unusable for any org repo.
446 func TestGitHTTP_AnonCloneOrgOwnedPublic(t *testing.T) {
447 t.Parallel()
448 env := setupEnv(t)
449
450 // Create an org owned by alice and a public repo under it.
451 org, err := orgs.Create(context.Background(), orgs.Deps{
452 Pool: env.pool,
453 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
454 Audit: audit.NewRecorder(),
455 }, orgs.CreateParams{
456 Slug: "myorg", DisplayName: "MyOrg", CreatedByUserID: env.userID,
457 })
458 if err != nil {
459 t.Fatalf("orgs.Create: %v", err)
460 }
461 rfs, err := storage.NewRepoFS(env.root)
462 if err != nil {
463 t.Fatalf("NewRepoFS: %v", err)
464 }
465 rdeps := repos.Deps{
466 Pool: env.pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
467 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
468 }
469 if _, err := repos.Create(context.Background(), rdeps, repos.Params{
470 OwnerOrgID: org.ID, OwnerSlug: org.Slug, ActorUserID: env.userID,
471 Name: "org-public", Visibility: "public", InitReadme: true,
472 }); err != nil {
473 t.Fatalf("create org repo: %v", err)
474 }
475
476 dst := filepath.Join(t.TempDir(), "clone")
477 out, err := gitCmd("clone", env.srv.URL+"/myorg/org-public.git", dst).CombinedOutput()
478 if err != nil {
479 t.Fatalf("clone org-owned repo: %v\n%s", err, out)
480 }
481 out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
482 if err != nil {
483 t.Fatalf("rev-list: %v\n%s", err, out)
484 }
485 if got := strings.TrimSpace(string(out)); got != "1" {
486 t.Fatalf("rev-list = %q, want 1", got)
487 }
488 }
489