@@ -22,10 +22,12 @@ import ( |
| 22 | | 22 | |
| 23 | "github.com/tenseleyFlow/shithub/internal/actions/finalize" | 23 | "github.com/tenseleyFlow/shithub/internal/actions/finalize" |
| 24 | "github.com/tenseleyFlow/shithub/internal/actions/runnertoken" | 24 | "github.com/tenseleyFlow/shithub/internal/actions/runnertoken" |
| | 25 | + actionsecrets "github.com/tenseleyFlow/shithub/internal/actions/secrets" |
| 25 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" | 26 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 26 | "github.com/tenseleyFlow/shithub/internal/actions/trigger" | 27 | "github.com/tenseleyFlow/shithub/internal/actions/trigger" |
| 27 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" | 28 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 28 | "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" | 29 | "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" |
| | 30 | + "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 29 | "github.com/tenseleyFlow/shithub/internal/infra/storage" | 31 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 30 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | 32 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 31 | "github.com/tenseleyFlow/shithub/internal/testing/dbtest" | 33 | "github.com/tenseleyFlow/shithub/internal/testing/dbtest" |
@@ -164,6 +166,133 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) { |
| 164 | } | 166 | } |
| 165 | } | 167 | } |
| 166 | | 168 | |
| | 169 | +func TestRunnerSecretsAreClaimedAndServerScrubsLogs(t *testing.T) { |
| | 170 | + ctx := context.Background() |
| | 171 | + pool := dbtest.NewTestDB(t) |
| | 172 | + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| | 173 | + repoID, userID := setupRunnerAPIRepo(t, pool) |
| | 174 | + runID := enqueueRunnerAPIRun(t, pool, logger, repoID, userID) |
| | 175 | + box := testRunnerAPISecretBox(t) |
| | 176 | + if err := (actionsecrets.Deps{Pool: pool, Box: box}).Set(ctx, actionsecrets.RepoScope(repoID), "TOKEN", []byte("hunter2"), userID); err != nil { |
| | 177 | + t.Fatalf("Set secret: %v", err) |
| | 178 | + } |
| | 179 | + |
| | 180 | + token, _ := registerRunnerForTest(t, pool, []string{"ubuntu-latest", "linux"}, 1) |
| | 181 | + signer := runnerAPISigner(t, time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)) |
| | 182 | + router := newRunnerAPIRouterWithSecretBox(t, pool, logger, signer, box) |
| | 183 | + |
| | 184 | + req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat", |
| | 185 | + strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`)) |
| | 186 | + req.Header.Set("Authorization", "Bearer "+token) |
| | 187 | + rr := httptest.NewRecorder() |
| | 188 | + router.ServeHTTP(rr, req) |
| | 189 | + if rr.Code != http.StatusOK { |
| | 190 | + t.Fatalf("heartbeat status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) |
| | 191 | + } |
| | 192 | + var claim struct { |
| | 193 | + Token string `json:"token"` |
| | 194 | + Job struct { |
| | 195 | + ID int64 `json:"id"` |
| | 196 | + RunID int64 `json:"run_id"` |
| | 197 | + Secrets map[string]string `json:"secrets"` |
| | 198 | + MaskValues []string `json:"mask_values"` |
| | 199 | + } `json:"job"` |
| | 200 | + } |
| | 201 | + if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil { |
| | 202 | + t.Fatalf("decode claim: %v", err) |
| | 203 | + } |
| | 204 | + if claim.Job.RunID != runID || claim.Job.Secrets["TOKEN"] != "hunter2" || !containsString(claim.Job.MaskValues, "hunter2") { |
| | 205 | + t.Fatalf("claim did not include masked secret context: %+v", claim.Job) |
| | 206 | + } |
| | 207 | + |
| | 208 | + rawLog := []byte("before hunter2 after\n") |
| | 209 | + logBody := fmt.Sprintf(`{"seq":0,"chunk":%q}`, base64.StdEncoding.EncodeToString(rawLog)) |
| | 210 | + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/jobs/%d/logs", claim.Job.ID), strings.NewReader(logBody)) |
| | 211 | + req.Header.Set("Authorization", "Bearer "+claim.Token) |
| | 212 | + rr = httptest.NewRecorder() |
| | 213 | + router.ServeHTTP(rr, req) |
| | 214 | + if rr.Code != http.StatusAccepted { |
| | 215 | + t.Fatalf("logs status: got %d, want 202; body=%s", rr.Code, rr.Body.String()) |
| | 216 | + } |
| | 217 | + step, err := actionsdb.New().GetFirstStepForJob(ctx, pool, claim.Job.ID) |
| | 218 | + if err != nil { |
| | 219 | + t.Fatalf("GetFirstStepForJob: %v", err) |
| | 220 | + } |
| | 221 | + chunks, err := actionsdb.New().ListStepLogChunks(ctx, pool, actionsdb.ListStepLogChunksParams{ |
| | 222 | + StepID: step.ID, |
| | 223 | + Seq: -1, |
| | 224 | + Limit: 10, |
| | 225 | + }) |
| | 226 | + if err != nil { |
| | 227 | + t.Fatalf("ListStepLogChunks: %v", err) |
| | 228 | + } |
| | 229 | + if len(chunks) != 1 { |
| | 230 | + t.Fatalf("chunks: %#v", chunks) |
| | 231 | + } |
| | 232 | + got := string(chunks[0].Chunk) |
| | 233 | + if strings.Contains(got, "hunter2") || got != "before *** after\n" { |
| | 234 | + t.Fatalf("stored log chunk was not scrubbed: %q", got) |
| | 235 | + } |
| | 236 | +} |
| | 237 | + |
| | 238 | +func TestRunnerServerScrubsSecretSplitAcrossLogPosts(t *testing.T) { |
| | 239 | + ctx := context.Background() |
| | 240 | + pool := dbtest.NewTestDB(t) |
| | 241 | + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| | 242 | + repoID, userID := setupRunnerAPIRepo(t, pool) |
| | 243 | + enqueueRunnerAPIRun(t, pool, logger, repoID, userID) |
| | 244 | + box := testRunnerAPISecretBox(t) |
| | 245 | + if err := (actionsecrets.Deps{Pool: pool, Box: box}).Set(ctx, actionsecrets.RepoScope(repoID), "TOKEN", []byte("hunter2"), userID); err != nil { |
| | 246 | + t.Fatalf("Set secret: %v", err) |
| | 247 | + } |
| | 248 | + |
| | 249 | + token, _ := registerRunnerForTest(t, pool, []string{"ubuntu-latest", "linux"}, 1) |
| | 250 | + signer := runnerAPISigner(t, time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)) |
| | 251 | + router := newRunnerAPIRouterWithSecretBox(t, pool, logger, signer, box) |
| | 252 | + |
| | 253 | + req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat", |
| | 254 | + strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`)) |
| | 255 | + req.Header.Set("Authorization", "Bearer "+token) |
| | 256 | + rr := httptest.NewRecorder() |
| | 257 | + router.ServeHTTP(rr, req) |
| | 258 | + if rr.Code != http.StatusOK { |
| | 259 | + t.Fatalf("heartbeat status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) |
| | 260 | + } |
| | 261 | + var claim struct { |
| | 262 | + Token string `json:"token"` |
| | 263 | + Job struct { |
| | 264 | + ID int64 `json:"id"` |
| | 265 | + } `json:"job"` |
| | 266 | + } |
| | 267 | + if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil { |
| | 268 | + t.Fatalf("decode claim: %v", err) |
| | 269 | + } |
| | 270 | + |
| | 271 | + next := postRunnerLogChunk(t, router, claim.Job.ID, claim.Token, 0, []byte("before hun")) |
| | 272 | + next = postRunnerLogChunk(t, router, claim.Job.ID, next, 1, []byte("ter2 after\n")) |
| | 273 | + |
| | 274 | + step, err := actionsdb.New().GetFirstStepForJob(ctx, pool, claim.Job.ID) |
| | 275 | + if err != nil { |
| | 276 | + t.Fatalf("GetFirstStepForJob: %v", err) |
| | 277 | + } |
| | 278 | + chunks, err := actionsdb.New().ListStepLogChunks(ctx, pool, actionsdb.ListStepLogChunksParams{ |
| | 279 | + StepID: step.ID, |
| | 280 | + Seq: -1, |
| | 281 | + Limit: 10, |
| | 282 | + }) |
| | 283 | + if err != nil { |
| | 284 | + t.Fatalf("ListStepLogChunks: %v", err) |
| | 285 | + } |
| | 286 | + var combined strings.Builder |
| | 287 | + for _, chunk := range chunks { |
| | 288 | + combined.Write(chunk.Chunk) |
| | 289 | + } |
| | 290 | + got := combined.String() |
| | 291 | + if strings.Contains(got, "hunter2") || got != "before *** after\n" { |
| | 292 | + t.Fatalf("stored log chunks were not scrubbed across boundary: chunks=%#v combined=%q next=%q", chunks, got, next) |
| | 293 | + } |
| | 294 | +} |
| | 295 | + |
| 167 | func TestRunnerHeartbeatRejectsBadToken(t *testing.T) { | 296 | func TestRunnerHeartbeatRejectsBadToken(t *testing.T) { |
| 168 | pool := dbtest.NewTestDB(t) | 297 | pool := dbtest.NewTestDB(t) |
| 169 | router := newRunnerAPIRouter(t, pool, slog.New(slog.NewTextHandler(io.Discard, nil)), runnerAPISigner(t, time.Now())) | 298 | router := newRunnerAPIRouter(t, pool, slog.New(slog.NewTextHandler(io.Discard, nil)), runnerAPISigner(t, time.Now())) |
@@ -311,6 +440,28 @@ func TestRunnerStepStatusEnqueuesFinalizeWorker(t *testing.T) { |
| 311 | } | 440 | } |
| 312 | } | 441 | } |
| 313 | | 442 | |
| | 443 | +func postRunnerLogChunk(t *testing.T, router http.Handler, jobID int64, token string, seq int32, chunk []byte) string { |
| | 444 | + t.Helper() |
| | 445 | + body := fmt.Sprintf(`{"seq":%d,"chunk":%q}`, seq, base64.StdEncoding.EncodeToString(chunk)) |
| | 446 | + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/jobs/%d/logs", jobID), strings.NewReader(body)) |
| | 447 | + req.Header.Set("Authorization", "Bearer "+token) |
| | 448 | + rr := httptest.NewRecorder() |
| | 449 | + router.ServeHTTP(rr, req) |
| | 450 | + if rr.Code != http.StatusAccepted { |
| | 451 | + t.Fatalf("logs status: got %d, want 202; body=%s", rr.Code, rr.Body.String()) |
| | 452 | + } |
| | 453 | + var resp struct { |
| | 454 | + NextToken string `json:"next_token"` |
| | 455 | + } |
| | 456 | + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { |
| | 457 | + t.Fatalf("decode log response: %v", err) |
| | 458 | + } |
| | 459 | + if resp.NextToken == "" { |
| | 460 | + t.Fatalf("empty next token in log response: %s", rr.Body.String()) |
| | 461 | + } |
| | 462 | + return resp.NextToken |
| | 463 | +} |
| | 464 | + |
| 314 | func newRunnerAPIRouter( | 465 | func newRunnerAPIRouter( |
| 315 | t *testing.T, | 466 | t *testing.T, |
| 316 | pool *pgxpool.Pool, | 467 | pool *pgxpool.Pool, |
@@ -337,6 +488,41 @@ func newRunnerAPIRouter( |
| 337 | return r | 488 | return r |
| 338 | } | 489 | } |
| 339 | | 490 | |
| | 491 | +func newRunnerAPIRouterWithSecretBox( |
| | 492 | + t *testing.T, |
| | 493 | + pool *pgxpool.Pool, |
| | 494 | + logger *slog.Logger, |
| | 495 | + signer *runnerjwt.Signer, |
| | 496 | + box *secretbox.Box, |
| | 497 | +) http.Handler { |
| | 498 | + t.Helper() |
| | 499 | + h, err := apih.New(apih.Deps{ |
| | 500 | + Pool: pool, |
| | 501 | + Logger: logger, |
| | 502 | + RunnerJWT: signer, |
| | 503 | + SecretBox: box, |
| | 504 | + }) |
| | 505 | + if err != nil { |
| | 506 | + t.Fatalf("api.New: %v", err) |
| | 507 | + } |
| | 508 | + r := chi.NewRouter() |
| | 509 | + h.Mount(r) |
| | 510 | + return r |
| | 511 | +} |
| | 512 | + |
| | 513 | +func testRunnerAPISecretBox(t *testing.T) *secretbox.Box { |
| | 514 | + t.Helper() |
| | 515 | + key, err := secretbox.GenerateKey() |
| | 516 | + if err != nil { |
| | 517 | + t.Fatalf("GenerateKey: %v", err) |
| | 518 | + } |
| | 519 | + box, err := secretbox.FromBytes(key) |
| | 520 | + if err != nil { |
| | 521 | + t.Fatalf("secretbox.FromBytes: %v", err) |
| | 522 | + } |
| | 523 | + return box |
| | 524 | +} |
| | 525 | + |
| 340 | func setupRunnerAPIRepo(t *testing.T, pool *pgxpool.Pool) (repoID, userID int64) { | 526 | func setupRunnerAPIRepo(t *testing.T, pool *pgxpool.Pool) (repoID, userID int64) { |
| 341 | t.Helper() | 527 | t.Helper() |
| 342 | ctx := context.Background() | 528 | ctx := context.Background() |
@@ -420,6 +606,15 @@ func registerRunnerForTest(t *testing.T, pool *pgxpool.Pool, labels []string, ca |
| 420 | return token, runner.ID | 606 | return token, runner.ID |
| 421 | } | 607 | } |
| 422 | | 608 | |
| | 609 | +func containsString(items []string, want string) bool { |
| | 610 | + for _, item := range items { |
| | 611 | + if item == want { |
| | 612 | + return true |
| | 613 | + } |
| | 614 | + } |
| | 615 | + return false |
| | 616 | +} |
| | 617 | + |
| 423 | func runnerAPISigner(t *testing.T, now time.Time) *runnerjwt.Signer { | 618 | func runnerAPISigner(t *testing.T, now time.Time) *runnerjwt.Signer { |
| 424 | t.Helper() | 619 | t.Helper() |
| 425 | signer, err := runnerjwt.NewFromKey( | 620 | signer, err := runnerjwt.NewFromKey( |