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