github.com/onflow/flow-go@v0.35.7-crescendo-preview.23-atree-inlining/engine/collection/compliance/core_test.go (about)

     1  package compliance
     2  
     3  import (
     4  	"errors"
     5  	"testing"
     6  
     7  	"github.com/stretchr/testify/assert"
     8  	"github.com/stretchr/testify/mock"
     9  	"github.com/stretchr/testify/require"
    10  	"github.com/stretchr/testify/suite"
    11  
    12  	hotstuff "github.com/onflow/flow-go/consensus/hotstuff/mocks"
    13  	"github.com/onflow/flow-go/consensus/hotstuff/model"
    14  	"github.com/onflow/flow-go/model/cluster"
    15  	"github.com/onflow/flow-go/model/flow"
    16  	"github.com/onflow/flow-go/model/messages"
    17  	realbuffer "github.com/onflow/flow-go/module/buffer"
    18  	"github.com/onflow/flow-go/module/compliance"
    19  	"github.com/onflow/flow-go/module/metrics"
    20  	module "github.com/onflow/flow-go/module/mock"
    21  	"github.com/onflow/flow-go/state"
    22  	clusterint "github.com/onflow/flow-go/state/cluster"
    23  	clusterstate "github.com/onflow/flow-go/state/cluster/mock"
    24  	storerr "github.com/onflow/flow-go/storage"
    25  	storage "github.com/onflow/flow-go/storage/mock"
    26  	"github.com/onflow/flow-go/utils/unittest"
    27  )
    28  
    29  func TestComplianceCore(t *testing.T) {
    30  	suite.Run(t, new(CoreSuite))
    31  }
    32  
    33  // CoreSuite tests the compliance core logic.
    34  type CoreSuite struct {
    35  	CommonSuite
    36  }
    37  
    38  // CommonSuite is shared between compliance core and engine testing.
    39  type CommonSuite struct {
    40  	suite.Suite
    41  
    42  	head *cluster.Block
    43  	// storage data
    44  	headerDB map[flow.Identifier]*cluster.Block
    45  
    46  	pendingDB  map[flow.Identifier]flow.Slashable[*cluster.Block]
    47  	childrenDB map[flow.Identifier][]flow.Slashable[*cluster.Block]
    48  
    49  	// mocked dependencies
    50  	state                     *clusterstate.MutableState
    51  	snapshot                  *clusterstate.Snapshot
    52  	metrics                   *metrics.NoopCollector
    53  	proposalViolationNotifier *hotstuff.ProposalViolationConsumer
    54  	headers                   *storage.Headers
    55  	pending                   *module.PendingClusterBlockBuffer
    56  	hotstuff                  *module.HotStuff
    57  	sync                      *module.BlockRequester
    58  	validator                 *hotstuff.Validator
    59  	voteAggregator            *hotstuff.VoteAggregator
    60  	timeoutAggregator         *hotstuff.TimeoutAggregator
    61  
    62  	// engine under test
    63  	core *Core
    64  }
    65  
    66  func (cs *CommonSuite) SetupTest() {
    67  	block := unittest.ClusterBlockFixture()
    68  	cs.head = &block
    69  
    70  	// initialize the storage data
    71  	cs.headerDB = make(map[flow.Identifier]*cluster.Block)
    72  	cs.pendingDB = make(map[flow.Identifier]flow.Slashable[*cluster.Block])
    73  	cs.childrenDB = make(map[flow.Identifier][]flow.Slashable[*cluster.Block])
    74  
    75  	// store the head header and payload
    76  	cs.headerDB[block.ID()] = cs.head
    77  
    78  	// set up header storage mock
    79  	cs.headers = &storage.Headers{}
    80  	cs.headers.On("ByBlockID", mock.Anything).Return(
    81  		func(blockID flow.Identifier) *flow.Header {
    82  			if header := cs.headerDB[blockID]; header != nil {
    83  				return cs.headerDB[blockID].Header
    84  			}
    85  			return nil
    86  		},
    87  		func(blockID flow.Identifier) error {
    88  			_, exists := cs.headerDB[blockID]
    89  			if !exists {
    90  				return storerr.ErrNotFound
    91  			}
    92  			return nil
    93  		},
    94  	)
    95  	cs.headers.On("Exists", mock.Anything).Return(
    96  		func(blockID flow.Identifier) bool {
    97  			_, exists := cs.headerDB[blockID]
    98  			return exists
    99  		}, func(blockID flow.Identifier) error {
   100  			return nil
   101  		})
   102  
   103  	// set up protocol state mock
   104  	cs.state = &clusterstate.MutableState{}
   105  	cs.state.On("Final").Return(
   106  		func() clusterint.Snapshot {
   107  			return cs.snapshot
   108  		},
   109  	)
   110  	cs.state.On("AtBlockID", mock.Anything).Return(
   111  		func(blockID flow.Identifier) clusterint.Snapshot {
   112  			return cs.snapshot
   113  		},
   114  	)
   115  	cs.state.On("Extend", mock.Anything).Return(nil)
   116  
   117  	// set up protocol snapshot mock
   118  	cs.snapshot = &clusterstate.Snapshot{}
   119  	cs.snapshot.On("Head").Return(
   120  		func() *flow.Header {
   121  			return cs.head.Header
   122  		},
   123  		nil,
   124  	)
   125  
   126  	// set up pending module mock
   127  	cs.pending = &module.PendingClusterBlockBuffer{}
   128  	cs.pending.On("Add", mock.Anything, mock.Anything).Return(true)
   129  	cs.pending.On("ByID", mock.Anything).Return(
   130  		func(blockID flow.Identifier) flow.Slashable[*cluster.Block] {
   131  			return cs.pendingDB[blockID]
   132  		},
   133  		func(blockID flow.Identifier) bool {
   134  			_, ok := cs.pendingDB[blockID]
   135  			return ok
   136  		},
   137  	)
   138  	cs.pending.On("ByParentID", mock.Anything).Return(
   139  		func(blockID flow.Identifier) []flow.Slashable[*cluster.Block] {
   140  			return cs.childrenDB[blockID]
   141  		},
   142  		func(blockID flow.Identifier) bool {
   143  			_, ok := cs.childrenDB[blockID]
   144  			return ok
   145  		},
   146  	)
   147  	cs.pending.On("DropForParent", mock.Anything).Return()
   148  	cs.pending.On("Size").Return(uint(0))
   149  	cs.pending.On("PruneByView", mock.Anything).Return()
   150  
   151  	closed := func() <-chan struct{} {
   152  		channel := make(chan struct{})
   153  		close(channel)
   154  		return channel
   155  	}()
   156  
   157  	// set up hotstuff module mock
   158  	cs.hotstuff = module.NewHotStuff(cs.T())
   159  
   160  	cs.validator = hotstuff.NewValidator(cs.T())
   161  	cs.voteAggregator = hotstuff.NewVoteAggregator(cs.T())
   162  	cs.timeoutAggregator = hotstuff.NewTimeoutAggregator(cs.T())
   163  
   164  	// set up synchronization module mock
   165  	cs.sync = &module.BlockRequester{}
   166  	cs.sync.On("RequestBlock", mock.Anything, mock.AnythingOfType("uint64")).Return(nil)
   167  	cs.sync.On("Done", mock.Anything).Return(closed)
   168  
   169  	// set up no-op metrics mock
   170  	cs.metrics = metrics.NewNoopCollector()
   171  
   172  	// set up notifier for reporting protocol violations
   173  	cs.proposalViolationNotifier = hotstuff.NewProposalViolationConsumer(cs.T())
   174  
   175  	// initialize the engine
   176  	core, err := NewCore(
   177  		unittest.Logger(),
   178  		cs.metrics,
   179  		cs.metrics,
   180  		cs.metrics,
   181  		cs.metrics,
   182  		cs.proposalViolationNotifier,
   183  		cs.headers,
   184  		cs.state,
   185  		cs.pending,
   186  		cs.sync,
   187  		cs.validator,
   188  		cs.hotstuff,
   189  		cs.voteAggregator,
   190  		cs.timeoutAggregator,
   191  		compliance.DefaultConfig(),
   192  	)
   193  	require.NoError(cs.T(), err, "engine initialization should pass")
   194  
   195  	cs.core = core
   196  }
   197  
   198  func (cs *CoreSuite) TestOnBlockProposalValidParent() {
   199  
   200  	// create a proposal that directly descends from the latest finalized header
   201  	originID := unittest.IdentifierFixture()
   202  	block := unittest.ClusterBlockWithParent(cs.head)
   203  
   204  	proposal := messages.NewClusterBlockProposal(&block)
   205  
   206  	// store the data for retrieval
   207  	cs.headerDB[block.Header.ParentID] = cs.head
   208  
   209  	hotstuffProposal := model.ProposalFromFlow(block.Header)
   210  	cs.validator.On("ValidateProposal", hotstuffProposal).Return(nil)
   211  	cs.voteAggregator.On("AddBlock", hotstuffProposal).Once()
   212  	cs.hotstuff.On("SubmitProposal", hotstuffProposal)
   213  
   214  	// it should be processed without error
   215  	err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   216  		OriginID: originID,
   217  		Message:  proposal,
   218  	})
   219  	require.NoError(cs.T(), err, "valid block proposal should pass")
   220  }
   221  
   222  func (cs *CoreSuite) TestOnBlockProposalValidAncestor() {
   223  
   224  	// create a proposal that has two ancestors in the cache
   225  	originID := unittest.IdentifierFixture()
   226  	ancestor := unittest.ClusterBlockWithParent(cs.head)
   227  	parent := unittest.ClusterBlockWithParent(&ancestor)
   228  	block := unittest.ClusterBlockWithParent(&parent)
   229  	proposal := messages.NewClusterBlockProposal(&block)
   230  
   231  	// store the data for retrieval
   232  	cs.headerDB[parent.ID()] = &parent
   233  	cs.headerDB[ancestor.ID()] = &ancestor
   234  
   235  	hotstuffProposal := model.ProposalFromFlow(block.Header)
   236  	cs.validator.On("ValidateProposal", hotstuffProposal).Return(nil)
   237  	cs.voteAggregator.On("AddBlock", hotstuffProposal).Once()
   238  	cs.hotstuff.On("SubmitProposal", hotstuffProposal).Once()
   239  
   240  	// it should be processed without error
   241  	err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   242  		OriginID: originID,
   243  		Message:  proposal,
   244  	})
   245  	require.NoError(cs.T(), err, "valid block proposal should pass")
   246  
   247  	// we should extend the state with the header
   248  	cs.state.AssertCalled(cs.T(), "Extend", &block)
   249  }
   250  
   251  func (cs *CoreSuite) TestOnBlockProposalSkipProposalThreshold() {
   252  
   253  	// create a proposal which is far enough ahead to be dropped
   254  	originID := unittest.IdentifierFixture()
   255  	block := unittest.ClusterBlockFixture()
   256  	block.Header.Height = cs.head.Header.Height + compliance.DefaultConfig().SkipNewProposalsThreshold + 1
   257  	proposal := unittest.ClusterProposalFromBlock(&block)
   258  
   259  	err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   260  		OriginID: originID,
   261  		Message:  proposal,
   262  	})
   263  	require.NoError(cs.T(), err)
   264  
   265  	// block should be dropped - not added to state or cache
   266  	cs.state.AssertNotCalled(cs.T(), "Extend", mock.Anything)
   267  	cs.pending.AssertNotCalled(cs.T(), "Add", originID, mock.Anything)
   268  }
   269  
   270  // TestOnBlockProposal_FailsHotStuffValidation tests that a proposal which fails HotStuff validation.
   271  //   - should not go through protocol state validation
   272  //   - should not be added to the state
   273  //   - we should not attempt to process its children
   274  //   - we should notify VoteAggregator, for known errors
   275  func (cs *CoreSuite) TestOnBlockProposal_FailsHotStuffValidation() {
   276  
   277  	// create a proposal that has two ancestors in the cache
   278  	originID := unittest.IdentifierFixture()
   279  	ancestor := unittest.ClusterBlockWithParent(cs.head)
   280  	parent := unittest.ClusterBlockWithParent(&ancestor)
   281  	block := unittest.ClusterBlockWithParent(&parent)
   282  	proposal := messages.NewClusterBlockProposal(&block)
   283  	hotstuffProposal := model.ProposalFromFlow(block.Header)
   284  
   285  	// store the data for retrieval
   286  	cs.headerDB[parent.ID()] = &parent
   287  	cs.headerDB[ancestor.ID()] = &ancestor
   288  
   289  	cs.Run("invalid block error", func() {
   290  		// the block fails HotStuff validation
   291  		*cs.validator = *hotstuff.NewValidator(cs.T())
   292  		sentinelError := model.NewInvalidProposalErrorf(hotstuffProposal, "")
   293  		cs.validator.On("ValidateProposal", hotstuffProposal).Return(sentinelError)
   294  		cs.proposalViolationNotifier.On("OnInvalidBlockDetected", flow.Slashable[model.InvalidProposalError]{
   295  			OriginID: originID,
   296  			Message:  sentinelError.(model.InvalidProposalError),
   297  		}).Return().Once()
   298  		// we should notify VoteAggregator about the invalid block
   299  		cs.voteAggregator.On("InvalidBlock", hotstuffProposal).Return(nil)
   300  
   301  		// the expected error should be handled within the Core
   302  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   303  			OriginID: originID,
   304  			Message:  proposal,
   305  		})
   306  		require.NoError(cs.T(), err, "proposal with invalid extension should fail")
   307  
   308  		// we should not extend the state with the header
   309  		cs.state.AssertNotCalled(cs.T(), "Extend", mock.Anything)
   310  		// we should not attempt to process the children
   311  		cs.pending.AssertNotCalled(cs.T(), "ByParentID", mock.Anything)
   312  	})
   313  
   314  	cs.Run("view for unknown epoch error", func() {
   315  		// the block fails HotStuff validation
   316  		*cs.validator = *hotstuff.NewValidator(cs.T())
   317  		cs.validator.On("ValidateProposal", hotstuffProposal).Return(model.ErrViewForUnknownEpoch)
   318  
   319  		// this error is not expected should raise an exception
   320  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   321  			OriginID: originID,
   322  			Message:  proposal,
   323  		})
   324  		require.Error(cs.T(), err, "proposal with invalid extension should fail")
   325  		require.NotErrorIs(cs.T(), err, model.ErrViewForUnknownEpoch)
   326  
   327  		// we should not extend the state with the header
   328  		cs.state.AssertNotCalled(cs.T(), "Extend", mock.Anything)
   329  		// we should not attempt to process the children
   330  		cs.pending.AssertNotCalled(cs.T(), "ByParentID", mock.Anything)
   331  	})
   332  
   333  	cs.Run("unexpected error", func() {
   334  		// the block fails HotStuff validation
   335  		unexpectedErr := errors.New("generic unexpected error")
   336  		*cs.validator = *hotstuff.NewValidator(cs.T())
   337  		cs.validator.On("ValidateProposal", hotstuffProposal).Return(unexpectedErr)
   338  
   339  		// the error should be propagated
   340  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   341  			OriginID: originID,
   342  			Message:  proposal,
   343  		})
   344  		require.ErrorIs(cs.T(), err, unexpectedErr)
   345  
   346  		// we should not extend the state with the header
   347  		cs.state.AssertNotCalled(cs.T(), "Extend", mock.Anything)
   348  		// we should not attempt to process the children
   349  		cs.pending.AssertNotCalled(cs.T(), "ByParentID", mock.Anything)
   350  	})
   351  }
   352  
   353  // TestOnBlockProposal_FailsProtocolStateValidation tests processing a proposal which passes HotStuff validation,
   354  // but fails protocol state validation.
   355  //   - should not be added to the state
   356  //   - we should not attempt to process its children
   357  //   - we should notify VoteAggregator, for known errors
   358  func (cs *CoreSuite) TestOnBlockProposal_FailsProtocolStateValidation() {
   359  
   360  	// create a proposal that has two ancestors in the cache
   361  	originID := unittest.IdentifierFixture()
   362  	ancestor := unittest.ClusterBlockWithParent(cs.head)
   363  	parent := unittest.ClusterBlockWithParent(&ancestor)
   364  	block := unittest.ClusterBlockWithParent(&parent)
   365  	proposal := messages.NewClusterBlockProposal(&block)
   366  	hotstuffProposal := model.ProposalFromFlow(block.Header)
   367  
   368  	// store the data for retrieval
   369  	cs.headerDB[parent.ID()] = &parent
   370  	cs.headerDB[ancestor.ID()] = &ancestor
   371  
   372  	// the block passes HotStuff validation
   373  	cs.validator.On("ValidateProposal", hotstuffProposal).Return(nil)
   374  
   375  	cs.Run("invalid block", func() {
   376  		// make sure we fail to extend the state
   377  		*cs.state = clusterstate.MutableState{}
   378  		cs.state.On("Final").Return(func() clusterint.Snapshot { return cs.snapshot })
   379  		sentinelErr := state.NewInvalidExtensionError("")
   380  		cs.state.On("Extend", mock.Anything).Return(sentinelErr)
   381  		cs.proposalViolationNotifier.On("OnInvalidBlockDetected", mock.Anything).Run(func(args mock.Arguments) {
   382  			err := args.Get(0).(flow.Slashable[model.InvalidProposalError])
   383  			require.ErrorIs(cs.T(), err.Message, sentinelErr)
   384  			require.Equal(cs.T(), err.Message.InvalidProposal, hotstuffProposal)
   385  			require.Equal(cs.T(), err.OriginID, originID)
   386  		}).Return().Once()
   387  		// we should notify VoteAggregator about the invalid block
   388  		cs.voteAggregator.On("InvalidBlock", hotstuffProposal).Return(nil)
   389  
   390  		// the expected error should be handled within the Core
   391  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   392  			OriginID: originID,
   393  			Message:  proposal,
   394  		})
   395  		require.NoError(cs.T(), err, "proposal with invalid extension should fail")
   396  
   397  		// we should extend the state with the header
   398  		cs.state.AssertCalled(cs.T(), "Extend", &block)
   399  		// we should not pass the block to hotstuff
   400  		cs.hotstuff.AssertNotCalled(cs.T(), "SubmitProposal", mock.Anything)
   401  		// we should not attempt to process the children
   402  		cs.pending.AssertNotCalled(cs.T(), "ByParentID", mock.Anything)
   403  	})
   404  
   405  	cs.Run("outdated block", func() {
   406  		// make sure we fail to extend the state
   407  		*cs.state = clusterstate.MutableState{}
   408  		cs.state.On("Final").Return(func() clusterint.Snapshot { return cs.snapshot })
   409  		cs.state.On("Extend", mock.Anything).Return(state.NewOutdatedExtensionError(""))
   410  
   411  		// the expected error should be handled within the Core
   412  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   413  			OriginID: originID,
   414  			Message:  proposal,
   415  		})
   416  		require.NoError(cs.T(), err, "proposal with invalid extension should fail")
   417  
   418  		// we should extend the state with the header
   419  		cs.state.AssertCalled(cs.T(), "Extend", &block)
   420  		// we should not pass the block to hotstuff
   421  		cs.hotstuff.AssertNotCalled(cs.T(), "SubmitProposal", mock.Anything)
   422  		// we should not attempt to process the children
   423  		cs.pending.AssertNotCalled(cs.T(), "ByParentID", mock.Anything)
   424  	})
   425  
   426  	cs.Run("unexpected error", func() {
   427  		// make sure we fail to extend the state
   428  		*cs.state = clusterstate.MutableState{}
   429  		cs.state.On("Final").Return(func() clusterint.Snapshot { return cs.snapshot })
   430  		unexpectedErr := errors.New("unexpected generic error")
   431  		cs.state.On("Extend", mock.Anything).Return(unexpectedErr)
   432  
   433  		// it should be processed without error
   434  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   435  			OriginID: originID,
   436  			Message:  proposal,
   437  		})
   438  		require.ErrorIs(cs.T(), err, unexpectedErr)
   439  
   440  		// we should extend the state with the header
   441  		cs.state.AssertCalled(cs.T(), "Extend", &block)
   442  		// we should not pass the block to hotstuff
   443  		cs.hotstuff.AssertNotCalled(cs.T(), "SubmitProposal", mock.Anything, mock.Anything)
   444  		// we should not attempt to process the children
   445  		cs.pending.AssertNotCalled(cs.T(), "ByParentID", mock.Anything)
   446  	})
   447  }
   448  
   449  func (cs *CoreSuite) TestProcessBlockAndDescendants() {
   450  
   451  	// create three children blocks
   452  	parent := unittest.ClusterBlockWithParent(cs.head)
   453  	block1 := unittest.ClusterBlockWithParent(&parent)
   454  	block2 := unittest.ClusterBlockWithParent(&parent)
   455  	block3 := unittest.ClusterBlockWithParent(&parent)
   456  
   457  	pendingFromBlock := func(block *cluster.Block) flow.Slashable[*cluster.Block] {
   458  		return flow.Slashable[*cluster.Block]{
   459  			OriginID: block.Header.ProposerID,
   460  			Message:  block,
   461  		}
   462  	}
   463  
   464  	// create the pending blocks
   465  	pending1 := pendingFromBlock(&block1)
   466  	pending2 := pendingFromBlock(&block2)
   467  	pending3 := pendingFromBlock(&block3)
   468  
   469  	// store the parent on disk
   470  	parentID := parent.ID()
   471  	cs.headerDB[parentID] = &parent
   472  
   473  	// store the pending children in the cache
   474  	cs.childrenDB[parentID] = append(cs.childrenDB[parentID], pending1)
   475  	cs.childrenDB[parentID] = append(cs.childrenDB[parentID], pending2)
   476  	cs.childrenDB[parentID] = append(cs.childrenDB[parentID], pending3)
   477  
   478  	for _, block := range []cluster.Block{parent, block1, block2, block3} {
   479  		hotstuffProposal := model.ProposalFromFlow(block.Header)
   480  		cs.validator.On("ValidateProposal", hotstuffProposal).Return(nil)
   481  		cs.voteAggregator.On("AddBlock", hotstuffProposal).Once()
   482  		cs.hotstuff.On("SubmitProposal", hotstuffProposal).Once()
   483  	}
   484  
   485  	// execute the connected children handling
   486  	err := cs.core.processBlockAndDescendants(flow.Slashable[*cluster.Block]{
   487  		OriginID: unittest.IdentifierFixture(),
   488  		Message:  &parent,
   489  	})
   490  	require.NoError(cs.T(), err, "should pass handling children")
   491  
   492  	// check that we submitted each child to hotstuff
   493  	cs.hotstuff.AssertExpectations(cs.T())
   494  
   495  	// make sure we drop the cache after trying to process
   496  	cs.pending.AssertCalled(cs.T(), "DropForParent", parent.Header.ID())
   497  }
   498  
   499  func (cs *CoreSuite) TestProposalBufferingOrder() {
   500  
   501  	// create a proposal that we will not submit until the end
   502  	originID := unittest.IdentifierFixture()
   503  	block := unittest.ClusterBlockWithParent(cs.head)
   504  	missing := &block
   505  
   506  	// create a chain of descendants
   507  	var proposals []*cluster.Block
   508  	proposalsLookup := make(map[flow.Identifier]*cluster.Block)
   509  	parent := missing
   510  	for i := 0; i < 3; i++ {
   511  		proposal := unittest.ClusterBlockWithParent(parent)
   512  		proposals = append(proposals, &proposal)
   513  		proposalsLookup[proposal.ID()] = &proposal
   514  		parent = &proposal
   515  	}
   516  
   517  	// replace the engine buffer with the real one
   518  	cs.core.pending = realbuffer.NewPendingClusterBlocks()
   519  
   520  	// process all of the descendants
   521  	for _, block := range proposals {
   522  
   523  		// check that we request the ancestor block each time
   524  		cs.sync.On("RequestBlock", mock.Anything, mock.AnythingOfType("uint64")).Once().Run(
   525  			func(args mock.Arguments) {
   526  				ancestorID := args.Get(0).(flow.Identifier)
   527  				assert.Equal(cs.T(), missing.Header.ID(), ancestorID, "should always request root block")
   528  			},
   529  		)
   530  
   531  		proposal := messages.NewClusterBlockProposal(block)
   532  
   533  		// process and make sure no error occurs (as they are unverifiable)
   534  		err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   535  			OriginID: originID,
   536  			Message:  proposal,
   537  		})
   538  		require.NoError(cs.T(), err, "proposal buffering should pass")
   539  
   540  		// make sure no block is forwarded to hotstuff
   541  		cs.hotstuff.AssertExpectations(cs.T())
   542  	}
   543  
   544  	// check that we submit ech proposal in order
   545  	*cs.hotstuff = module.HotStuff{}
   546  	index := 0
   547  	order := []flow.Identifier{
   548  		missing.Header.ID(),
   549  		proposals[0].Header.ID(),
   550  		proposals[1].Header.ID(),
   551  		proposals[2].Header.ID(),
   552  	}
   553  	cs.hotstuff.On("SubmitProposal", mock.Anything).Times(4).Run(
   554  		func(args mock.Arguments) {
   555  			header := args.Get(0).(*model.Proposal).Block
   556  			assert.Equal(cs.T(), order[index], header.BlockID, "should submit correct header to hotstuff")
   557  			index++
   558  			cs.headerDB[header.BlockID] = proposalsLookup[header.BlockID]
   559  		},
   560  	)
   561  	cs.voteAggregator.On("AddBlock", mock.Anything).Times(4)
   562  	cs.validator.On("ValidateProposal", mock.Anything).Times(4).Return(nil)
   563  
   564  	missingProposal := messages.NewClusterBlockProposal(missing)
   565  
   566  	proposalsLookup[missing.ID()] = missing
   567  
   568  	// process the root proposal
   569  	err := cs.core.OnBlockProposal(flow.Slashable[*messages.ClusterBlockProposal]{
   570  		OriginID: originID,
   571  		Message:  missingProposal,
   572  	})
   573  	require.NoError(cs.T(), err, "root proposal should pass")
   574  
   575  	// make sure we submitted all four proposals
   576  	cs.hotstuff.AssertExpectations(cs.T())
   577  }