github.com/letsencrypt/boulder@v0.20251208.0/crl/updater/updater_test.go (about) 1 package updater 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "io" 9 "testing" 10 "time" 11 12 "google.golang.org/grpc" 13 "google.golang.org/protobuf/types/known/emptypb" 14 "google.golang.org/protobuf/types/known/timestamppb" 15 16 "github.com/jmhodges/clock" 17 "github.com/prometheus/client_golang/prometheus" 18 19 capb "github.com/letsencrypt/boulder/ca/proto" 20 corepb "github.com/letsencrypt/boulder/core/proto" 21 cspb "github.com/letsencrypt/boulder/crl/storer/proto" 22 "github.com/letsencrypt/boulder/issuance" 23 blog "github.com/letsencrypt/boulder/log" 24 "github.com/letsencrypt/boulder/metrics" 25 "github.com/letsencrypt/boulder/revocation" 26 sapb "github.com/letsencrypt/boulder/sa/proto" 27 "github.com/letsencrypt/boulder/test" 28 ) 29 30 // revokedCertsStream is a fake grpc.ClientStreamingClient which can be 31 // populated with some CRL entries or an error for use as the return value of 32 // a faked GetRevokedCertsByShard call. 33 type revokedCertsStream struct { 34 grpc.ClientStream 35 entries []*corepb.CRLEntry 36 nextIdx int 37 err error 38 } 39 40 func (f *revokedCertsStream) Recv() (*corepb.CRLEntry, error) { 41 if f.err != nil { 42 return nil, f.err 43 } 44 if f.nextIdx < len(f.entries) { 45 res := f.entries[f.nextIdx] 46 f.nextIdx++ 47 return res, nil 48 } 49 return nil, io.EOF 50 } 51 52 // fakeSAC is a fake sapb.StorageAuthorityClient which can be populated with a 53 // fakeGRCC to be used as the return value for calls to GetRevokedCertsByShard, 54 // and a fake timestamp to serve as the database's maximum notAfter value. 55 type fakeSAC struct { 56 sapb.StorageAuthorityClient 57 revokedCerts revokedCertsStream 58 maxNotAfter time.Time 59 leaseError error 60 } 61 62 // Return the configured stream. 63 func (f *fakeSAC) GetRevokedCertsByShard(ctx context.Context, req *sapb.GetRevokedCertsByShardRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[corepb.CRLEntry], error) { 64 return &f.revokedCerts, nil 65 } 66 67 func (f *fakeSAC) LeaseCRLShard(_ context.Context, req *sapb.LeaseCRLShardRequest, _ ...grpc.CallOption) (*sapb.LeaseCRLShardResponse, error) { 68 if f.leaseError != nil { 69 return nil, f.leaseError 70 } 71 return &sapb.LeaseCRLShardResponse{IssuerNameID: req.IssuerNameID, ShardIdx: req.MinShardIdx}, nil 72 } 73 74 // generateCRLStream implements the streaming API returned from GenerateCRL. 75 // 76 // Specifically it implements grpc.BidiStreamingClient. 77 // 78 // If it has non-nil error fields, it returns those on Send() or Recv(). 79 // 80 // When it receives a CRL entry (on Send()), it records that entry internally, JSON serialized, 81 // with a newline between JSON objects. 82 // 83 // When it is asked for bytes of a signed CRL (Recv()), it sends those JSON serialized contents. 84 // 85 // We use JSON instead of CRL format because we're not testing the signing and formatting done 86 // by the CA, just the plumbing of different components together done by the crl-updater. 87 type generateCRLStream struct { 88 grpc.ClientStream 89 chunks [][]byte 90 nextIdx int 91 sendErr error 92 recvErr error 93 } 94 95 type crlEntry struct { 96 Serial string 97 Reason int32 98 RevokedAt time.Time 99 } 100 101 func (f *generateCRLStream) Send(req *capb.GenerateCRLRequest) error { 102 if f.sendErr != nil { 103 return f.sendErr 104 } 105 if t, ok := req.Payload.(*capb.GenerateCRLRequest_Entry); ok { 106 jsonBytes, err := json.Marshal(crlEntry{ 107 Serial: t.Entry.Serial, 108 Reason: t.Entry.Reason, 109 RevokedAt: t.Entry.RevokedAt.AsTime(), 110 }) 111 if err != nil { 112 return err 113 } 114 f.chunks = append(f.chunks, jsonBytes) 115 f.chunks = append(f.chunks, []byte("\n")) 116 } 117 return f.sendErr 118 } 119 120 func (f *generateCRLStream) CloseSend() error { 121 return nil 122 } 123 124 func (f *generateCRLStream) Recv() (*capb.GenerateCRLResponse, error) { 125 if f.recvErr != nil { 126 return nil, f.recvErr 127 } 128 if f.nextIdx < len(f.chunks) { 129 res := f.chunks[f.nextIdx] 130 f.nextIdx++ 131 return &capb.GenerateCRLResponse{Chunk: res}, nil 132 } 133 return nil, io.EOF 134 } 135 136 // fakeCA acts as a fake CA (specifically implementing capb.CRLGeneratorClient). 137 // 138 // It always returns its field in response to `GenerateCRL`. Because this is a streaming 139 // RPC, that return value is responsible for most of the work. 140 type fakeCA struct { 141 gcc generateCRLStream 142 } 143 144 func (f *fakeCA) GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[capb.GenerateCRLRequest, capb.GenerateCRLResponse], error) { 145 return &f.gcc, nil 146 } 147 148 // recordingUploader acts as the streaming part of UploadCRL. 149 // 150 // Records all uploaded chunks in crlBody. 151 type recordingUploader struct { 152 grpc.ClientStream 153 154 crlBody []byte 155 } 156 157 func (r *recordingUploader) Send(req *cspb.UploadCRLRequest) error { 158 if t, ok := req.Payload.(*cspb.UploadCRLRequest_CrlChunk); ok { 159 r.crlBody = append(r.crlBody, t.CrlChunk...) 160 } 161 return nil 162 } 163 164 func (r *recordingUploader) CloseAndRecv() (*emptypb.Empty, error) { 165 return &emptypb.Empty{}, nil 166 } 167 168 // noopUploader is a fake grpc.ClientStreamingClient which can be populated with 169 // an error for use as the return value of a faked UploadCRL call. 170 // 171 // It does nothing with uploaded contents. 172 type noopUploader struct { 173 grpc.ClientStream 174 sendErr error 175 recvErr error 176 } 177 178 func (f *noopUploader) Send(*cspb.UploadCRLRequest) error { 179 return f.sendErr 180 } 181 182 func (f *noopUploader) CloseAndRecv() (*emptypb.Empty, error) { 183 if f.recvErr != nil { 184 return nil, f.recvErr 185 } 186 return &emptypb.Empty{}, nil 187 } 188 189 // fakeStorer is a fake cspb.CRLStorerClient which can be populated with an 190 // uploader stream for use as the return value for calls to UploadCRL. 191 type fakeStorer struct { 192 uploaderStream grpc.ClientStreamingClient[cspb.UploadCRLRequest, emptypb.Empty] 193 } 194 195 func (f *fakeStorer) UploadCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[cspb.UploadCRLRequest, emptypb.Empty], error) { 196 return f.uploaderStream, nil 197 } 198 199 func TestUpdateShard(t *testing.T) { 200 e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem") 201 test.AssertNotError(t, err, "loading test issuer") 202 r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem") 203 test.AssertNotError(t, err, "loading test issuer") 204 205 sentinelErr := errors.New("oops") 206 ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 207 defer cancel() 208 209 clk := clock.NewFake() 210 clk.Set(time.Date(2020, time.January, 18, 0, 0, 0, 0, time.UTC)) 211 cu, err := NewUpdater( 212 []*issuance.Certificate{e1, r3}, 213 2, 214 18*time.Hour, // shardWidth 215 24*time.Hour, // lookbackPeriod 216 6*time.Hour, // updatePeriod 217 time.Minute, // updateTimeout 218 1, 1, 219 "stale-if-error=60", 220 5*time.Minute, 221 &fakeSAC{ 222 revokedCerts: revokedCertsStream{}, 223 maxNotAfter: clk.Now().Add(90 * 24 * time.Hour), 224 }, 225 &fakeCA{gcc: generateCRLStream{}}, 226 &fakeStorer{uploaderStream: &noopUploader{}}, 227 metrics.NoopRegisterer, blog.NewMock(), clk, 228 ) 229 test.AssertNotError(t, err, "building test crlUpdater") 230 231 // Ensure that getting no results from the SA still works. 232 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 233 test.AssertNotError(t, err, "empty CRL") 234 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 235 "issuer": "(TEST) Elegant Elephant E1", "result": "success", 236 }, 1) 237 238 // Make a CRL with actual contents. Verify that the information makes it through 239 // each of the steps: 240 // - read from SA 241 // - write to CA and read the response 242 // - upload with CRL storer 243 // 244 // The final response should show up in the bytes recorded by our fake storer. 245 recordingUploader := &recordingUploader{} 246 now := timestamppb.Now() 247 cu.cs = &fakeStorer{uploaderStream: recordingUploader} 248 cu.sa = &fakeSAC{ 249 revokedCerts: revokedCertsStream{ 250 entries: []*corepb.CRLEntry{ 251 { 252 Serial: "0311b5d430823cfa25b0fc85d14c54ee35", 253 Reason: int32(revocation.KeyCompromise), 254 RevokedAt: now, 255 }, 256 { 257 Serial: "037d6a05a0f6a975380456ae605cee9889", 258 Reason: int32(revocation.AffiliationChanged), 259 RevokedAt: now, 260 }, 261 { 262 Serial: "03aa617ab8ee58896ba082bfa25199c884", 263 Reason: int32(revocation.Unspecified), 264 RevokedAt: now, 265 }, 266 }, 267 }, 268 maxNotAfter: clk.Now().Add(90 * 24 * time.Hour), 269 } 270 // We ask for shard 2 specifically because GetRevokedCertsByShard only returns our 271 // certificate for that shard. 272 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 2) 273 test.AssertNotError(t, err, "updateShard") 274 275 expectedEntries := map[string]int32{ 276 "0311b5d430823cfa25b0fc85d14c54ee35": int32(revocation.KeyCompromise), 277 "037d6a05a0f6a975380456ae605cee9889": int32(revocation.AffiliationChanged), 278 "03aa617ab8ee58896ba082bfa25199c884": int32(revocation.Unspecified), 279 } 280 for r := range bytes.SplitSeq(recordingUploader.crlBody, []byte("\n")) { 281 if len(r) == 0 { 282 continue 283 } 284 var entry crlEntry 285 err := json.Unmarshal(r, &entry) 286 if err != nil { 287 t.Fatalf("unmarshaling JSON: %s", err) 288 } 289 expectedReason, ok := expectedEntries[entry.Serial] 290 if !ok { 291 t.Errorf("CRL entry for %s was unexpected", entry.Serial) 292 } 293 if entry.Reason != expectedReason { 294 t.Errorf("CRL entry for %s had reason=%d, want %d", entry.Serial, entry.Reason, expectedReason) 295 } 296 delete(expectedEntries, entry.Serial) 297 } 298 // At this point the expectedEntries map should be empty; if it's not, emit an error 299 // for each remaining expectation. 300 for k, v := range expectedEntries { 301 t.Errorf("expected cert %s to be revoked for reason=%d, but it was not on the CRL", k, v) 302 } 303 304 cu.updatedCounter.Reset() 305 306 // Ensure that getting no results from the SA still works. 307 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 308 test.AssertNotError(t, err, "empty CRL") 309 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 310 "issuer": "(TEST) Elegant Elephant E1", "result": "success", 311 }, 1) 312 cu.updatedCounter.Reset() 313 314 // Errors closing the Storer upload stream should bubble up. 315 cu.cs = &fakeStorer{uploaderStream: &noopUploader{recvErr: sentinelErr}} 316 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 317 test.AssertError(t, err, "storer error") 318 test.AssertContains(t, err.Error(), "closing CRLStorer upload stream") 319 test.AssertErrorIs(t, err, sentinelErr) 320 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 321 "issuer": "(TEST) Elegant Elephant E1", "result": "failed", 322 }, 1) 323 cu.updatedCounter.Reset() 324 325 // Errors sending to the Storer should bubble up sooner. 326 cu.cs = &fakeStorer{uploaderStream: &noopUploader{sendErr: sentinelErr}} 327 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 328 test.AssertError(t, err, "storer error") 329 test.AssertContains(t, err.Error(), "sending CRLStorer metadata") 330 test.AssertErrorIs(t, err, sentinelErr) 331 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 332 "issuer": "(TEST) Elegant Elephant E1", "result": "failed", 333 }, 1) 334 cu.updatedCounter.Reset() 335 336 // Errors reading from the CA should bubble up sooner. 337 cu.ca = &fakeCA{gcc: generateCRLStream{recvErr: sentinelErr}} 338 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 339 test.AssertError(t, err, "CA error") 340 test.AssertContains(t, err.Error(), "receiving CRL bytes") 341 test.AssertErrorIs(t, err, sentinelErr) 342 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 343 "issuer": "(TEST) Elegant Elephant E1", "result": "failed", 344 }, 1) 345 cu.updatedCounter.Reset() 346 347 // Errors sending to the CA should bubble up sooner. 348 cu.ca = &fakeCA{gcc: generateCRLStream{sendErr: sentinelErr}} 349 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 350 test.AssertError(t, err, "CA error") 351 test.AssertContains(t, err.Error(), "sending CA metadata") 352 test.AssertErrorIs(t, err, sentinelErr) 353 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 354 "issuer": "(TEST) Elegant Elephant E1", "result": "failed", 355 }, 1) 356 cu.updatedCounter.Reset() 357 358 // Errors reading from the SA should bubble up soonest. 359 cu.sa = &fakeSAC{revokedCerts: revokedCertsStream{err: sentinelErr}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)} 360 err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1) 361 test.AssertError(t, err, "database error") 362 test.AssertContains(t, err.Error(), "retrieving entry from SA") 363 test.AssertErrorIs(t, err, sentinelErr) 364 test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{ 365 "issuer": "(TEST) Elegant Elephant E1", "result": "failed", 366 }, 1) 367 cu.updatedCounter.Reset() 368 } 369 370 func TestUpdateShardWithRetry(t *testing.T) { 371 e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem") 372 test.AssertNotError(t, err, "loading test issuer") 373 r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem") 374 test.AssertNotError(t, err, "loading test issuer") 375 376 sentinelErr := errors.New("oops") 377 ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 378 defer cancel() 379 380 clk := clock.NewFake() 381 clk.Set(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) 382 383 // Build an updater that will always fail when it talks to the SA. 384 cu, err := NewUpdater( 385 []*issuance.Certificate{e1, r3}, 386 2, 18*time.Hour, 24*time.Hour, 387 6*time.Hour, time.Minute, 1, 1, 388 "stale-if-error=60", 389 5*time.Minute, 390 &fakeSAC{revokedCerts: revokedCertsStream{err: sentinelErr}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)}, 391 &fakeCA{gcc: generateCRLStream{}}, 392 &fakeStorer{uploaderStream: &noopUploader{}}, 393 metrics.NoopRegisterer, blog.NewMock(), clk, 394 ) 395 test.AssertNotError(t, err, "building test crlUpdater") 396 397 // Ensure that having MaxAttempts set to 1 results in the clock not moving 398 // forward at all. 399 startTime := cu.clk.Now() 400 err = cu.updateShardWithRetry(ctx, cu.clk.Now(), e1.NameID(), 1) 401 test.AssertError(t, err, "database error") 402 test.AssertErrorIs(t, err, sentinelErr) 403 test.AssertEquals(t, cu.clk.Now(), startTime) 404 405 // Ensure that having MaxAttempts set to 5 results in the clock moving forward 406 // by 1+2+4+8=15 seconds. The core.RetryBackoff system has 20% jitter built 407 // in, so we have to be approximate. 408 cu.maxAttempts = 5 409 startTime = cu.clk.Now() 410 err = cu.updateShardWithRetry(ctx, cu.clk.Now(), e1.NameID(), 1) 411 test.AssertError(t, err, "database error") 412 test.AssertErrorIs(t, err, sentinelErr) 413 t.Logf("start: %v", startTime) 414 t.Logf("now: %v", cu.clk.Now()) 415 test.Assert(t, startTime.Add(15*0.8*time.Second).Before(cu.clk.Now()), "retries didn't sleep enough") 416 test.Assert(t, startTime.Add(15*1.2*time.Second).After(cu.clk.Now()), "retries slept too much") 417 }