@@ -19,8 +19,10 @@ import ( |
| 19 | 19 | "github.com/jackc/pgx/v5/pgtype" |
| 20 | 20 | "github.com/jackc/pgx/v5/pgxpool" |
| 21 | 21 | |
| 22 | + actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 22 | 23 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 23 | 24 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 25 | + "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" |
| 24 | 26 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 25 | 27 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 26 | 28 | "github.com/tenseleyFlow/shithub/internal/orgs" |
@@ -45,15 +47,18 @@ func gitCmd(args ...string) *exec.Cmd { |
| 45 | 47 | // private repo, mounts the smart-HTTP handlers on a httptest server, |
| 46 | 48 | // and returns everything callers need. |
| 47 | 49 | type env struct { |
| 48 | | - srv *httptest.Server |
| 49 | | - pool *pgxpool.Pool |
| 50 | | - userID int64 |
| 51 | | - user string |
| 52 | | - pwd string |
| 53 | | - patRaw string |
| 54 | | - pubRepo string |
| 55 | | - privRepo string |
| 56 | | - root string |
| 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 |
| 57 | 62 | } |
| 58 | 63 | |
| 59 | 64 | func setupEnv(t *testing.T) *env { |
@@ -89,16 +94,18 @@ func setupEnv(t *testing.T) *env { |
| 89 | 94 | Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(), |
| 90 | 95 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 91 | 96 | } |
| 92 | | - if _, err := repos.Create(context.Background(), rdeps, repos.Params{ |
| 97 | + publicRes, err := repos.Create(context.Background(), rdeps, repos.Params{ |
| 93 | 98 | OwnerUserID: user.ID, OwnerUsername: user.Username, |
| 94 | 99 | Name: "public-repo", Visibility: "public", InitReadme: true, |
| 95 | | - }); err != nil { |
| 100 | + }) |
| 101 | + if err != nil { |
| 96 | 102 | t.Fatalf("create public: %v", err) |
| 97 | 103 | } |
| 98 | | - if _, err := repos.Create(context.Background(), rdeps, repos.Params{ |
| 104 | + privateRes, err := repos.Create(context.Background(), rdeps, repos.Params{ |
| 99 | 105 | OwnerUserID: user.ID, OwnerUsername: user.Username, |
| 100 | 106 | Name: "private-repo", Visibility: "private", InitReadme: true, |
| 101 | | - }); err != nil { |
| 107 | + }) |
| 108 | + if err != nil { |
| 102 | 109 | t.Fatalf("create private: %v", err) |
| 103 | 110 | } |
| 104 | 111 | |
@@ -119,9 +126,10 @@ func setupEnv(t *testing.T) *env { |
| 119 | 126 | t.Fatalf("create PAT row: %v", err) |
| 120 | 127 | } |
| 121 | 128 | |
| 129 | + runnerJWT := runnerHTTPSigner(t) |
| 122 | 130 | h, err := githttph.New(githttph.Deps{ |
| 123 | 131 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 124 | | - Pool: pool, RepoFS: rfs, |
| 132 | + Pool: pool, RepoFS: rfs, RunnerJWT: runnerJWT, |
| 125 | 133 | }) |
| 126 | 134 | if err != nil { |
| 127 | 135 | t.Fatalf("New: %v", err) |
@@ -134,7 +142,9 @@ func setupEnv(t *testing.T) *env { |
| 134 | 142 | return &env{ |
| 135 | 143 | srv: srv, pool: pool, userID: user.ID, |
| 136 | 144 | user: "alice", pwd: "wrong-not-real-password", patRaw: raw, |
| 137 | | - pubRepo: "public-repo", privRepo: "private-repo", root: root, |
| 145 | + pubRepo: "public-repo", pubRepoID: publicRes.Repo.ID, |
| 146 | + privRepo: "private-repo", privRepoID: privateRes.Repo.ID, |
| 147 | + root: root, runnerJWT: runnerJWT, |
| 138 | 148 | } |
| 139 | 149 | } |
| 140 | 150 | |
@@ -196,6 +206,66 @@ func TestGitHTTP_PATClonePrivate(t *testing.T) { |
| 196 | 206 | } |
| 197 | 207 | } |
| 198 | 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 | + |
| 199 | 269 | func TestGitHTTP_PATPushRoundtrip(t *testing.T) { |
| 200 | 270 | t.Parallel() |
| 201 | 271 | env := setupEnv(t) |
@@ -291,6 +361,82 @@ func writeFile(path, body string) error { |
| 291 | 361 | return os.WriteFile(path, []byte(body), 0o600) |
| 292 | 362 | } |
| 293 | 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 | + |
| 294 | 440 | // TestGitHTTP_AnonCloneOrgOwnedPublic is a regression for an outage |
| 295 | 441 | // on 2026-05-09: pushing to https://shithub.sh/tenseleyflow/shithub.git |
| 296 | 442 | // returned 404 because authorizeForService only resolved owner-slugs |