@@ -1375,6 +1375,144 @@ function directoryBodyWithKeyChange(bool $withRenewalInfo = false): array
13751375 expect ($ callCount )->toBe (2 ); // pending → valid after one retry
13761376});
13771377
1378+ // ── Certificate::getBundle() preferred chain selection ────────────────────────
1379+
1380+ function makeCertBundle (string $ issuerCN = 'Test Issuer CA ' ): string
1381+ {
1382+ $ issuerKey = openssl_pkey_new (['private_key_type ' => OPENSSL_KEYTYPE_RSA , 'private_key_bits ' => 2048 ]);
1383+ $ issuerCsr = openssl_csr_new (['commonName ' => $ issuerCN ], $ issuerKey );
1384+ $ issuerCert = openssl_csr_sign ($ issuerCsr , null , $ issuerKey , 3650 , serial: 2 );
1385+ openssl_x509_export ($ issuerCert , $ issuerPem );
1386+
1387+ $ leafKey = openssl_pkey_new (['private_key_type ' => OPENSSL_KEYTYPE_RSA , 'private_key_bits ' => 2048 ]);
1388+ $ leafCsr = openssl_csr_new (['commonName ' => 'leaf.example.com ' ], $ leafKey );
1389+ $ leafCert = openssl_csr_sign ($ leafCsr , $ issuerCert , $ issuerKey , 365 , serial: 3 );
1390+ openssl_x509_export ($ leafCert , $ leafPem );
1391+
1392+ return $ leafPem . "\n" . $ issuerPem ;
1393+ }
1394+
1395+ function certOrderData (): OrderData
1396+ {
1397+ return new OrderData (
1398+ id: '1 ' ,
1399+ url: 'https://acme.example/order/1 ' ,
1400+ status: 'valid ' ,
1401+ expires: '2099-01-01T00:00:00Z ' ,
1402+ identifiers: [],
1403+ domainValidationUrls: [],
1404+ finalizeUrl: 'https://acme.example/finalize/1 ' ,
1405+ accountUrl: 'https://acme.example/account/1 ' ,
1406+ certificateUrl: 'https://acme.example/cert/1 ' ,
1407+ finalized: true ,
1408+ );
1409+ }
1410+
1411+ it ('Certificate::getBundle() returns preferred alternate chain when issuer CN matches ' , function () {
1412+ $ storage = withKeyStorage ();
1413+ $ primaryBundle = makeCertBundle ('Default Root CA ' );
1414+ $ alternateBundle = makeCertBundle ('ISRG Root X1 ' );
1415+
1416+ $ mock = closureMock (
1417+ getHandler: fn ($ url ) => new Response ([], $ url , 200 , directoryBody ()),
1418+ postHandler: function ($ url ) use ($ primaryBundle , $ alternateBundle ) {
1419+ if (str_contains ($ url , '/alt ' )) {
1420+ return new Response ([], $ url , 200 , $ alternateBundle );
1421+ }
1422+
1423+ return new Response (
1424+ ['link ' => '<https://acme.example/cert/1/alt>;rel="alternate" ' ],
1425+ $ url ,
1426+ 200 ,
1427+ $ primaryBundle ,
1428+ );
1429+ },
1430+ );
1431+
1432+ $ bundle = makeEndpointApi ($ mock , $ storage )->certificate ()->getBundle (certOrderData (), 'ISRG Root X1 ' );
1433+
1434+ preg_match_all ('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i ' , $ bundle ->caBundle , $ m );
1435+ $ parsed = openssl_x509_parse ($ m [1 ][0 ]);
1436+ expect ($ parsed ['subject ' ]['CN ' ])->toBe ('ISRG Root X1 ' );
1437+ });
1438+
1439+ it ('Certificate::getBundle() falls back to primary when no alternate chain matches preferred issuer ' , function () {
1440+ $ storage = withKeyStorage ();
1441+ $ primaryBundle = makeCertBundle ('Default Root CA ' );
1442+ $ alternateBundle = makeCertBundle ('Some Other CA ' );
1443+
1444+ $ mock = closureMock (
1445+ getHandler: fn ($ url ) => new Response ([], $ url , 200 , directoryBody ()),
1446+ postHandler: function ($ url ) use ($ primaryBundle , $ alternateBundle ) {
1447+ if (str_contains ($ url , '/alt ' )) {
1448+ return new Response ([], $ url , 200 , $ alternateBundle );
1449+ }
1450+
1451+ return new Response (
1452+ ['link ' => '<https://acme.example/cert/1/alt>;rel="alternate" ' ],
1453+ $ url ,
1454+ 200 ,
1455+ $ primaryBundle ,
1456+ );
1457+ },
1458+ );
1459+
1460+ $ bundle = makeEndpointApi ($ mock , $ storage )->certificate ()->getBundle (certOrderData (), 'ISRG Root X1 ' );
1461+
1462+ preg_match_all ('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i ' , $ bundle ->caBundle , $ m );
1463+ $ parsed = openssl_x509_parse ($ m [1 ][0 ]);
1464+ expect ($ parsed ['subject ' ]['CN ' ])->toBe ('Default Root CA ' );
1465+ });
1466+
1467+ it ('Certificate::getBundle() returns primary chain when no Link header is present with preferredChain set ' , function () {
1468+ $ storage = withKeyStorage ();
1469+ $ primaryBundle = makeCertBundle ('Default Root CA ' );
1470+
1471+ $ mock = closureMock (
1472+ getHandler: fn ($ url ) => new Response ([], $ url , 200 , directoryBody ()),
1473+ postHandler: fn ($ url ) => new Response ([], $ url , 200 , $ primaryBundle ),
1474+ );
1475+
1476+ $ bundle = makeEndpointApi ($ mock , $ storage )->certificate ()->getBundle (certOrderData (), 'ISRG Root X1 ' );
1477+
1478+ preg_match_all ('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i ' , $ bundle ->caBundle , $ m );
1479+ $ parsed = openssl_x509_parse ($ m [1 ][0 ]);
1480+ expect ($ parsed ['subject ' ]['CN ' ])->toBe ('Default Root CA ' );
1481+ });
1482+
1483+ it ('Certificate::getBundle() handles multiple Link headers (comma-separated) and selects matching chain ' , function () {
1484+ $ storage = withKeyStorage ();
1485+ $ primaryBundle = makeCertBundle ('Default Root CA ' );
1486+ $ alternate1Bundle = makeCertBundle ('Other CA ' );
1487+ $ alternate2Bundle = makeCertBundle ('ISRG Root X1 ' );
1488+
1489+ $ mock = closureMock (
1490+ getHandler: fn ($ url ) => new Response ([], $ url , 200 , directoryBody ()),
1491+ postHandler: function ($ url ) use ($ primaryBundle , $ alternate1Bundle , $ alternate2Bundle ) {
1492+ if (str_contains ($ url , '/alt2 ' )) {
1493+ return new Response ([], $ url , 200 , $ alternate2Bundle );
1494+ }
1495+
1496+ if (str_contains ($ url , '/alt1 ' )) {
1497+ return new Response ([], $ url , 200 , $ alternate1Bundle );
1498+ }
1499+
1500+ return new Response (
1501+ ['link ' => '<https://acme.example/cert/1/alt1>;rel="alternate", <https://acme.example/cert/1/alt2>;rel="alternate" ' ],
1502+ $ url ,
1503+ 200 ,
1504+ $ primaryBundle ,
1505+ );
1506+ },
1507+ );
1508+
1509+ $ bundle = makeEndpointApi ($ mock , $ storage )->certificate ()->getBundle (certOrderData (), 'ISRG Root X1 ' );
1510+
1511+ preg_match_all ('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i ' , $ bundle ->caBundle , $ m );
1512+ $ parsed = openssl_x509_parse ($ m [1 ][0 ]);
1513+ expect ($ parsed ['subject ' ]['CN ' ])->toBe ('ISRG Root X1 ' );
1514+ });
1515+
13781516// ── Certificate::getBundle() null certificateUrl path (line 15) ──────────────
13791517
13801518it ('Certificate::getBundle() throws AcmeException when certificateUrl is null ' , function () {
0 commit comments