github.com/letsencrypt/boulder@v0.20251208.0/sa/model_test.go (about) 1 package sa 2 3 import ( 4 "context" 5 "crypto/ecdsa" 6 "crypto/elliptic" 7 "crypto/rand" 8 "crypto/x509" 9 "crypto/x509/pkix" 10 "database/sql" 11 "fmt" 12 "math/big" 13 "net/netip" 14 "slices" 15 "testing" 16 "time" 17 18 "github.com/jmhodges/clock" 19 "google.golang.org/protobuf/proto" 20 "google.golang.org/protobuf/types/known/timestamppb" 21 22 "github.com/letsencrypt/boulder/db" 23 "github.com/letsencrypt/boulder/grpc" 24 "github.com/letsencrypt/boulder/identifier" 25 "github.com/letsencrypt/boulder/probs" 26 sapb "github.com/letsencrypt/boulder/sa/proto" 27 "github.com/letsencrypt/boulder/test/vars" 28 29 "github.com/letsencrypt/boulder/core" 30 corepb "github.com/letsencrypt/boulder/core/proto" 31 "github.com/letsencrypt/boulder/test" 32 ) 33 34 func TestRegistrationModelToPb(t *testing.T) { 35 badCases := []struct { 36 name string 37 input regModel 38 }{ 39 { 40 name: "No ID", 41 input: regModel{ID: 0, Key: []byte("foo")}, 42 }, 43 { 44 name: "No Key", 45 input: regModel{ID: 1, Key: nil}, 46 }, 47 } 48 for _, tc := range badCases { 49 t.Run(tc.name, func(t *testing.T) { 50 _, err := registrationModelToPb(&tc.input) 51 test.AssertError(t, err, "Should fail") 52 }) 53 } 54 55 _, err := registrationModelToPb(®Model{ID: 1, Key: []byte("foo")}) 56 test.AssertNotError(t, err, "Should pass") 57 } 58 59 func TestAuthzModel(t *testing.T) { 60 // newTestAuthzPB returns a new *corepb.Authorization for `example.com` that 61 // is valid, and contains a single valid HTTP-01 challenge. These are the 62 // most common authorization attributes used in tests. Some tests will 63 // customize them after calling this. 64 newTestAuthzPB := func(validated time.Time) *corepb.Authorization { 65 return &corepb.Authorization{ 66 Id: "1", 67 Identifier: identifier.NewDNS("example.com").ToProto(), 68 RegistrationID: 1, 69 Status: string(core.StatusValid), 70 Expires: timestamppb.New(validated.Add(24 * time.Hour)), 71 Challenges: []*corepb.Challenge{ 72 { 73 Type: string(core.ChallengeTypeHTTP01), 74 Status: string(core.StatusValid), 75 Token: "MTIz", 76 Validated: timestamppb.New(validated), 77 Validationrecords: []*corepb.ValidationRecord{ 78 { 79 AddressUsed: []byte("1.2.3.4"), 80 Url: "https://example.com", 81 Hostname: "example.com", 82 Port: "443", 83 AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, 84 AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, 85 }, 86 }, 87 }, 88 }, 89 } 90 } 91 92 clk := clock.New() 93 94 authzPB := newTestAuthzPB(clk.Now()) 95 authzPB.CertificateProfileName = "test" 96 97 model, err := authzPBToModel(authzPB) 98 test.AssertNotError(t, err, "authzPBToModel failed") 99 100 authzPBOut, err := modelToAuthzPB(*model) 101 test.AssertNotError(t, err, "modelToAuthzPB failed") 102 if authzPB.Challenges[0].Validationrecords[0].Hostname != "" { 103 test.Assert(t, false, fmt.Sprintf("dehydrated http-01 validation record expected hostname field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Hostname)) 104 } 105 if authzPB.Challenges[0].Validationrecords[0].Port != "" { 106 test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Port)) 107 } 108 // Shoving the Hostname and Port back into the validation record should 109 // succeed because authzPB validation record should match the retrieved 110 // model from the database with the rehydrated Hostname and Port. 111 authzPB.Challenges[0].Validationrecords[0].Hostname = "example.com" 112 authzPB.Challenges[0].Validationrecords[0].Port = "443" 113 test.AssertDeepEquals(t, authzPB.Challenges, authzPBOut.Challenges) 114 test.AssertEquals(t, authzPBOut.CertificateProfileName, authzPB.CertificateProfileName) 115 116 authzPB = newTestAuthzPB(clk.Now()) 117 118 validationErr := probs.Connection("weewoo") 119 120 authzPB.Challenges[0].Status = string(core.StatusInvalid) 121 authzPB.Challenges[0].Error, err = grpc.ProblemDetailsToPB(validationErr) 122 test.AssertNotError(t, err, "grpc.ProblemDetailsToPB failed") 123 model, err = authzPBToModel(authzPB) 124 test.AssertNotError(t, err, "authzPBToModel failed") 125 126 authzPBOut, err = modelToAuthzPB(*model) 127 test.AssertNotError(t, err, "modelToAuthzPB failed") 128 if authzPB.Challenges[0].Validationrecords[0].Hostname != "" { 129 test.Assert(t, false, fmt.Sprintf("dehydrated http-01 validation record expected hostname field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Hostname)) 130 } 131 if authzPB.Challenges[0].Validationrecords[0].Port != "" { 132 test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port field to be missing, but found %v", authzPB.Challenges[0].Validationrecords[0].Port)) 133 } 134 // Shoving the Hostname and Port back into the validation record should 135 // succeed because authzPB validation record should match the retrieved 136 // model from the database with the rehydrated Hostname and Port. 137 authzPB.Challenges[0].Validationrecords[0].Hostname = "example.com" 138 authzPB.Challenges[0].Validationrecords[0].Port = "443" 139 test.AssertDeepEquals(t, authzPB.Challenges, authzPBOut.Challenges) 140 141 authzPB = newTestAuthzPB(clk.Now()) 142 authzPB.Status = string(core.StatusInvalid) 143 authzPB.Challenges = []*corepb.Challenge{ 144 { 145 Type: string(core.ChallengeTypeHTTP01), 146 Status: string(core.StatusInvalid), 147 Token: "MTIz", 148 Validationrecords: []*corepb.ValidationRecord{ 149 { 150 AddressUsed: []byte("1.2.3.4"), 151 Url: "url", 152 AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, 153 AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, 154 }, 155 }, 156 }, 157 { 158 Type: string(core.ChallengeTypeDNS01), 159 Status: string(core.StatusInvalid), 160 Token: "MTIz", 161 Validationrecords: []*corepb.ValidationRecord{ 162 { 163 AddressUsed: []byte("1.2.3.4"), 164 Url: "url", 165 AddressesResolved: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, 166 AddressesTried: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}}, 167 }, 168 }, 169 }, 170 } 171 _, err = authzPBToModel(authzPB) 172 test.AssertError(t, err, "authzPBToModel didn't fail with multiple non-pending challenges") 173 174 // Test that the caller Hostname and Port rehydration returns the expected 175 // data in the expected fields. 176 authzPB = newTestAuthzPB(clk.Now()) 177 178 model, err = authzPBToModel(authzPB) 179 test.AssertNotError(t, err, "authzPBToModel failed") 180 181 authzPBOut, err = modelToAuthzPB(*model) 182 test.AssertNotError(t, err, "modelToAuthzPB failed") 183 if authzPBOut.Challenges[0].Validationrecords[0].Hostname != "example.com" { 184 test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected hostname example.com but found %v", authzPBOut.Challenges[0].Validationrecords[0].Hostname)) 185 } 186 if authzPBOut.Challenges[0].Validationrecords[0].Port != "443" { 187 test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port 443 but found %v", authzPBOut.Challenges[0].Validationrecords[0].Port)) 188 } 189 190 authzPB = newTestAuthzPB(clk.Now()) 191 authzPB.Identifier = identifier.NewIP(netip.MustParseAddr("1.2.3.4")).ToProto() 192 authzPB.Challenges[0].Validationrecords[0].Url = "https://1.2.3.4" 193 authzPB.Challenges[0].Validationrecords[0].Hostname = "1.2.3.4" 194 195 model, err = authzPBToModel(authzPB) 196 test.AssertNotError(t, err, "authzPBToModel failed") 197 authzPBOut, err = modelToAuthzPB(*model) 198 test.AssertNotError(t, err, "modelToAuthzPB failed") 199 200 identOut := identifier.FromProto(authzPBOut.Identifier) 201 if identOut.Type != identifier.TypeIP { 202 test.Assert(t, false, fmt.Sprintf("expected identifier type ip but found %s", identOut.Type)) 203 } 204 if identOut.Value != "1.2.3.4" { 205 test.Assert(t, false, fmt.Sprintf("expected identifier value 1.2.3.4 but found %s", identOut.Value)) 206 } 207 208 if authzPBOut.Challenges[0].Validationrecords[0].Hostname != "1.2.3.4" { 209 test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected hostname 1.2.3.4 but found %v", authzPBOut.Challenges[0].Validationrecords[0].Hostname)) 210 } 211 if authzPBOut.Challenges[0].Validationrecords[0].Port != "443" { 212 test.Assert(t, false, fmt.Sprintf("rehydrated http-01 validation record expected port 443 but found %v", authzPBOut.Challenges[0].Validationrecords[0].Port)) 213 } 214 } 215 216 // TestModelToOrderBADJSON tests that converting an order model with an invalid 217 // validation error JSON field to an Order produces the expected bad JSON error. 218 func TestModelToOrderBadJSON(t *testing.T) { 219 badJSON := []byte(`{`) 220 _, err := modelToOrder(&orderModel{ 221 Error: badJSON, 222 }) 223 test.AssertError(t, err, "expected error from modelToOrderv2") 224 var badJSONErr errBadJSON 225 test.AssertErrorWraps(t, err, &badJSONErr) 226 test.AssertEquals(t, string(badJSONErr.json), string(badJSON)) 227 } 228 229 // TestModelToOrderAuthzs tests that the Authzs field is properly decoded and 230 // assigned to V2Authorizations. 231 func TestModelToOrderAuthzs(t *testing.T) { 232 expectedAuthzIDs := []int64{1, 2, 3, 42} 233 encodedAuthzs, err := proto.Marshal(&sapb.Authzs{AuthzIDs: expectedAuthzIDs}) 234 test.AssertNotError(t, err, "failed to marshal authzs") 235 236 testCases := []struct { 237 name string 238 model *orderModel 239 expectedAuthzIDs []int64 240 }{ 241 { 242 name: "with authzs", 243 model: &orderModel{Authzs: encodedAuthzs}, 244 expectedAuthzIDs: expectedAuthzIDs, 245 }, 246 { 247 name: "without authzs", 248 model: &orderModel{}, 249 expectedAuthzIDs: nil, 250 }, 251 } 252 for _, tc := range testCases { 253 t.Run(tc.name, func(t *testing.T) { 254 order, err := modelToOrder(tc.model) 255 if err != nil { 256 t.Fatalf("modelToOrder(%v) = %s, want success", tc.model, err) 257 } 258 if !slices.Equal(order.V2Authorizations, tc.expectedAuthzIDs) { 259 t.Errorf("modelToOrder(%v) = %v, want %v", tc.model, order.V2Authorizations, tc.expectedAuthzIDs) 260 } 261 }) 262 } 263 } 264 265 // TestPopulateAttemptedFieldsBadJSON tests that populating a challenge from an 266 // authz2 model with an invalid validation error or an invalid validation record 267 // produces the expected bad JSON error. 268 func TestPopulateAttemptedFieldsBadJSON(t *testing.T) { 269 badJSON := []byte(`{`) 270 271 testCases := []struct { 272 Name string 273 Model *authzModel 274 }{ 275 { 276 Name: "Bad validation error field", 277 Model: &authzModel{ 278 ValidationError: badJSON, 279 }, 280 }, 281 { 282 Name: "Bad validation record field", 283 Model: &authzModel{ 284 ValidationRecord: badJSON, 285 }, 286 }, 287 } 288 for _, tc := range testCases { 289 t.Run(tc.Name, func(t *testing.T) { 290 err := populateAttemptedFields(*tc.Model, &corepb.Challenge{}) 291 test.AssertError(t, err, "expected error from populateAttemptedFields") 292 var badJSONErr errBadJSON 293 test.AssertErrorWraps(t, err, &badJSONErr) 294 test.AssertEquals(t, string(badJSONErr.json), string(badJSON)) 295 }) 296 } 297 } 298 299 func TestCertificatesTableContainsDuplicateSerials(t *testing.T) { 300 ctx := context.Background() 301 302 sa, fc, cleanUp := initSA(t) 303 defer cleanUp() 304 305 serialString := core.SerialToString(big.NewInt(1337)) 306 307 // Insert a certificate with a serial of `1337`. 308 err := insertCertificate(ctx, sa.dbMap, fc, "1337.com", "leet", 1337, 1) 309 test.AssertNotError(t, err, "couldn't insert valid certificate") 310 311 // This should return the certificate that we just inserted. 312 certA, err := SelectCertificate(ctx, sa.dbMap, serialString) 313 test.AssertNotError(t, err, "received an error for a valid query") 314 315 // Insert a certificate with a serial of `1337` but for a different 316 // hostname. 317 err = insertCertificate(ctx, sa.dbMap, fc, "1337.net", "leet", 1337, 1) 318 test.AssertNotError(t, err, "couldn't insert valid certificate") 319 320 // Despite a duplicate being present, this shouldn't error. 321 certB, err := SelectCertificate(ctx, sa.dbMap, serialString) 322 test.AssertNotError(t, err, "received an error for a valid query") 323 324 // Ensure that `certA` and `certB` are the same. 325 test.AssertByteEquals(t, certA.Der, certB.Der) 326 } 327 328 func insertCertificate(ctx context.Context, dbMap *db.WrappedMap, fc clock.FakeClock, hostname, cn string, serial, regID int64) error { 329 serialBigInt := big.NewInt(serial) 330 serialString := core.SerialToString(serialBigInt) 331 332 template := x509.Certificate{ 333 Subject: pkix.Name{ 334 CommonName: cn, 335 }, 336 NotAfter: fc.Now().Add(30 * 24 * time.Hour), 337 DNSNames: []string{hostname}, 338 SerialNumber: serialBigInt, 339 } 340 341 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 342 if err != nil { 343 return fmt.Errorf("generating test key: %w", err) 344 } 345 certDer, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) 346 if err != nil { 347 return fmt.Errorf("generating test cert: %w", err) 348 } 349 cert := &core.Certificate{ 350 RegistrationID: regID, 351 Issued: fc.Now(), 352 Serial: serialString, 353 Expires: template.NotAfter, 354 DER: certDer, 355 } 356 err = dbMap.Insert(ctx, cert) 357 if err != nil { 358 return err 359 } 360 return nil 361 } 362 363 func TestIncidentSerialModel(t *testing.T) { 364 ctx := context.Background() 365 366 testIncidentsDbMap, err := DBMapForTest(vars.DBConnIncidentsFullPerms) 367 test.AssertNotError(t, err, "Couldn't create test dbMap") 368 defer test.ResetIncidentsTestDatabase(t) 369 370 // Inserting and retrieving a row with only the serial populated should work. 371 _, err = testIncidentsDbMap.ExecContext(ctx, 372 "INSERT INTO incident_foo (serial) VALUES (?)", 373 "1337", 374 ) 375 test.AssertNotError(t, err, "inserting row with only serial") 376 377 var res1 incidentSerialModel 378 err = testIncidentsDbMap.SelectOne( 379 ctx, 380 &res1, 381 "SELECT * FROM incident_foo WHERE serial = ?", 382 "1337", 383 ) 384 test.AssertNotError(t, err, "selecting row with only serial") 385 386 test.AssertEquals(t, res1.Serial, "1337") 387 test.AssertBoxedNil(t, res1.RegistrationID, "registrationID should be NULL") 388 test.AssertBoxedNil(t, res1.OrderID, "orderID should be NULL") 389 test.AssertBoxedNil(t, res1.LastNoticeSent, "lastNoticeSent should be NULL") 390 391 // Inserting and retrieving a row with all columns populated should work. 392 _, err = testIncidentsDbMap.ExecContext(ctx, 393 "INSERT INTO incident_foo (serial, registrationID, orderID, lastNoticeSent) VALUES (?, ?, ?, ?)", 394 "1338", 395 1, 396 2, 397 time.Date(2023, 06, 29, 16, 9, 00, 00, time.UTC), 398 ) 399 test.AssertNotError(t, err, "inserting row with only serial") 400 401 var res2 incidentSerialModel 402 err = testIncidentsDbMap.SelectOne( 403 ctx, 404 &res2, 405 "SELECT * FROM incident_foo WHERE serial = ?", 406 "1338", 407 ) 408 test.AssertNotError(t, err, "selecting row with only serial") 409 410 test.AssertEquals(t, res2.Serial, "1338") 411 test.AssertEquals(t, *res2.RegistrationID, int64(1)) 412 test.AssertEquals(t, *res2.OrderID, int64(2)) 413 test.AssertEquals(t, *res2.LastNoticeSent, time.Date(2023, 06, 29, 16, 9, 00, 00, time.UTC)) 414 } 415 416 func TestAddReplacementOrder(t *testing.T) { 417 sa, _, cleanUp := initSA(t) 418 defer cleanUp() 419 420 oldCertSerial := "1234567890" 421 orderId := int64(1337) 422 orderExpires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) 423 424 // Add a replacement order which doesn't exist. 425 err := addReplacementOrder(ctx, sa.dbMap, oldCertSerial, orderId, orderExpires) 426 test.AssertNotError(t, err, "addReplacementOrder failed") 427 428 // Fetch the replacement order so we can ensure it was added. 429 var replacementRow replacementOrderModel 430 err = sa.dbReadOnlyMap.SelectOne( 431 ctx, 432 &replacementRow, 433 "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", 434 oldCertSerial, 435 ) 436 test.AssertNotError(t, err, "SELECT from replacementOrders failed") 437 test.AssertEquals(t, oldCertSerial, replacementRow.Serial) 438 test.AssertEquals(t, orderId, replacementRow.OrderID) 439 test.AssertEquals(t, orderExpires, replacementRow.OrderExpires) 440 441 nextOrderId := int64(1338) 442 nextOrderExpires := time.Now().Add(48 * time.Hour).UTC().Truncate(time.Second) 443 444 // Add a replacement order which already exists. 445 err = addReplacementOrder(ctx, sa.dbMap, oldCertSerial, nextOrderId, nextOrderExpires) 446 test.AssertNotError(t, err, "addReplacementOrder failed") 447 448 // Fetch the replacement order so we can ensure it was updated. 449 err = sa.dbReadOnlyMap.SelectOne( 450 ctx, 451 &replacementRow, 452 "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", 453 oldCertSerial, 454 ) 455 test.AssertNotError(t, err, "SELECT from replacementOrders failed") 456 test.AssertEquals(t, oldCertSerial, replacementRow.Serial) 457 test.AssertEquals(t, nextOrderId, replacementRow.OrderID) 458 test.AssertEquals(t, nextOrderExpires, replacementRow.OrderExpires) 459 } 460 461 func TestSetReplacementOrderFinalized(t *testing.T) { 462 sa, _, cleanUp := initSA(t) 463 defer cleanUp() 464 465 oldCertSerial := "1234567890" 466 orderId := int64(1337) 467 orderExpires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) 468 469 // Mark a non-existent certificate as finalized/replaced. 470 err := setReplacementOrderFinalized(ctx, sa.dbMap, orderId) 471 test.AssertNotError(t, err, "setReplacementOrderFinalized failed") 472 473 // Ensure no replacement order was added for some reason. 474 var replacementRow replacementOrderModel 475 err = sa.dbReadOnlyMap.SelectOne( 476 ctx, 477 &replacementRow, 478 "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", 479 oldCertSerial, 480 ) 481 test.AssertErrorIs(t, err, sql.ErrNoRows) 482 483 // Add a replacement order. 484 err = addReplacementOrder(ctx, sa.dbMap, oldCertSerial, orderId, orderExpires) 485 test.AssertNotError(t, err, "addReplacementOrder failed") 486 487 // Mark the certificate as finalized/replaced. 488 err = setReplacementOrderFinalized(ctx, sa.dbMap, orderId) 489 test.AssertNotError(t, err, "setReplacementOrderFinalized failed") 490 491 // Fetch the replacement order so we can ensure it was finalized. 492 err = sa.dbReadOnlyMap.SelectOne( 493 ctx, 494 &replacementRow, 495 "SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1", 496 oldCertSerial, 497 ) 498 test.AssertNotError(t, err, "SELECT from replacementOrders failed") 499 test.Assert(t, replacementRow.Replaced, "replacement order should be marked as finalized") 500 }