⋯ 428 hidden lines@@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re 440 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) 441 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) 441 } 442 } 442 443 443 - type fakeGuard struct {444 + type fakePushTrees struct {444 - path string 445 + path string 445 - err error 446 + err error 446 - got []domain.RefUpdate 447 + diffErr error 447 - objs ports.PushObjects 448 + got []domain.RefUpdate 449 + objs ports.PushObjects 450 + bounds domain.PushWalkBounds 451 + subtreePath string 452 + calls int 453 + old, new domain.ObjectID 454 + data *domain.PushSubtrees 455 + change bool 448 } 456 } 449 457 450 - func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {458 + func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {451 - g.got, g.objs = ups, o 459 + g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds 452 - return g.path, g.err 460 + if g.data != nil { 461 + return *g.data, g.err 462 + } 463 + t := domain.PushSubtrees{Ends: map [string]domain.PushObjectType{}, Subtrees: map [string]domain.Subtree{}} 464 + for _, up := range ups { 465 + t.Ends[up.Old] = domain.PushObjectCommit 466 + t.Ends[up.New] = domain.PushObjectCommit 467 + if g.path != "" || g.change { 468 + id, _ := domain.ParseObjectID(up.New) 469 + t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id} 470 + } 471 + } 472 + return t, g.err 473 + } 474 + 475 + func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) {476 + g.calls++ 477 + g.old, g.new = old, new 478 + return strings.TrimPrefix(g.path, domain.WorkflowDir+"/" ), g.diffErr 453 } 479 } 454 480 455 // A push by the token of a job is judged on the workflows it changes, and by it 481 // A push by the token of a job is judged on the workflows it changes, and by it ⋯ 1 hidden line@@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { 475 501 476 t.Run("no workflow touched" , func (t *testing.T) { 502 t.Run("no workflow touched" , func (t *testing.T) { 477 t.Parallel() 503 t.Parallel() 478 - g := &fakeGuard{} 504 + g := &fakePushTrees{} 479 f := build(t, g) 505 f := build(t, g) 480 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) 506 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) 481 assert.Equal(t, ups, g.got, "every ref is judged" ) 507 assert.Equal(t, ups, g.got, "every ref is judged" ) 482 assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine" ) 508 assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine" ) 509 + assert.Equal(t, domain.WorkflowDir, g.subtreePath) 510 + assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds) 511 + assert.Zero(t, g.calls) 483 }) 512 }) 484 t.Run("a workflow touched refuses the whole push with GitHub's text" , func (t *testing.T) { 513 t.Run("a workflow touched refuses the whole push with GitHub's text" , func (t *testing.T) { 485 t.Parallel() 514 t.Parallel() 486 - f := build(t, &fakeGuard{path: ".github/workflows/ci.yml" }) 515 + f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml" }) 487 err := f.pushes.Check(ctx, job, n, ups, objs) 516 err := f.pushes.Check(ctx, job, n, ups, objs) 488 require.ErrorIs(t, err, domain.ErrRejected) 517 require.ErrorIs(t, err, domain.ErrRejected) 489 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission" ) 518 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission" ) 490 }) 519 }) 520 + t.Run("empty differing entry reports the directory and uses the domain pair once" , func (t *testing.T) { 521 + g := &fakePushTrees{change: true } 522 + f := build(t, g) 523 + err := f.pushes.Check(ctx, job, n, ups, objs) 524 + require.ErrorIs(t, err, domain.ErrRejected) 525 + assert.Contains(t, err.Error(), "workflow `.github/workflows`" ) 526 + assert.Equal(t, 1, g.calls) 527 + assert.True(t, g.old.IsZero()) 528 + assert.Equal(t, oidB, g.new.String()) 529 + }) 530 + t.Run("diff invalid is rejected with reason" , func (t *testing.T) { 531 + g := &fakePushTrees{change: true , diffErr: fmt.Errorf("%w: path too long" , domain.ErrInvalid)} 532 + f := build(t, g) 533 + err := f.pushes.Check(ctx, job, n, ups, objs) 534 + require.ErrorIs(t, err, domain.ErrRejected) 535 + assert.Contains(t, err.Error(), "path too long" ) 536 + assert.Equal(t, 1, g.calls) 537 + }) 538 + t.Run("diff I/O failure retains its class" , func (t *testing.T) { 539 + boom := errors.New("diff broke" ) 540 + g := &fakePushTrees{change: true , diffErr: boom} 541 + f := build(t, g) 542 + err := f.pushes.Check(ctx, job, n, ups, objs) 543 + require.ErrorIs(t, err, boom) 544 + assert.NotErrorIs(t, err, domain.ErrRejected) 545 + }) 546 + t.Run("missing endpoint is rejected before diff" , func (t *testing.T) { 547 + g := &fakePushTrees{data: &domain.PushSubtrees{}} 548 + f := build(t, g) 549 + err := f.pushes.Check(ctx, job, n, ups, objs) 550 + require.ErrorIs(t, err, domain.ErrRejected) 551 + assert.Zero(t, g.calls) 552 + }) 491 t.Run("a failure to tell refuses it, not as a policy" , func (t *testing.T) { 553 t.Run("a failure to tell refuses it, not as a policy" , func (t *testing.T) { 492 t.Parallel() 554 t.Parallel() 493 - f := build(t, &fakeGuard{err: errors.New("git broke" )}) 555 + f := build(t, &fakePushTrees{err: errors.New("git broke" )}) 494 err := f.pushes.Check(ctx, job, n, ups, objs) 556 err := f.pushes.Check(ctx, job, n, ups, objs) 495 require.Error(t, err) 557 require.Error(t, err) 496 assert.NotErrorIs(t, err, domain.ErrRejected) 558 assert.NotErrorIs(t, err, domain.ErrRejected) 497 }) 559 }) 498 t.Run("a push beyond the guard's bounds is refused as a policy, with the reason" , func (t *testing.T) { 560 t.Run("a push beyond the guard's bounds is refused as a policy, with the reason" , func (t *testing.T) { 499 t.Parallel() 561 t.Parallel() 500 - f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits" , domain.ErrInvalid)}) 562 + f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits" , domain.ErrInvalid)}) 501 err := f.pushes.Check(ctx, job, n, ups, objs) 563 err := f.pushes.Check(ctx, job, n, ups, objs) 502 require.ErrorIs(t, err, domain.ErrRejected) 564 require.ErrorIs(t, err, domain.ErrRejected) 503 assert.Contains(t, err.Error(), "more than 10000 commits" ) 565 assert.Contains(t, err.Error(), "more than 10000 commits" ) ⋯ 5 hidden lines@@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { 509 }) 571 }) 510 t.Run("a job token without contents: write cannot push at all" , func (t *testing.T) { 572 t.Run("a job token without contents: write cannot push at all" , func (t *testing.T) { 511 t.Parallel() 573 t.Parallel() 512 - f := build(t, &fakeGuard{}) 574 + f := build(t, &fakePushTrees{}) 513 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) 575 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) 514 }) 576 }) 515 t.Run("the owner is not asked" , func (t *testing.T) { 577 t.Run("the owner is not asked" , func (t *testing.T) { 516 t.Parallel() 578 t.Parallel() 517 - g := &fakeGuard{path: ".github/workflows/ci.yml" } 579 + g := &fakePushTrees{path: ".github/workflows/ci.yml" } 518 f := build(t, g) 580 f := build(t, g) 519 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) 581 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) 520 assert.Nil(t, g.got) 582 assert.Nil(t, g.got) 521 }) 583 }) 522 t.Run("a job token never creates a repository on push" , func (t *testing.T) { 584 t.Run("a job token never creates a repository on push" , func (t *testing.T) { 523 t.Parallel() 585 t.Parallel() 524 - f := build(t, &fakeGuard{}) 586 + f := build(t, &fakePushTrees{}) 525 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) 587 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) 526 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true ), domain.ErrNotFound) 588 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true ), domain.ErrNotFound) 527 }) 589 })