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 }