diff --git a/src/Cdn/Certificates/Provider/Proxy.php b/src/Cdn/Certificates/Provider/Proxy.php index aaa47b9..609c34e 100644 --- a/src/Cdn/Certificates/Provider/Proxy.php +++ b/src/Cdn/Certificates/Provider/Proxy.php @@ -21,6 +21,7 @@ public function __construct( public function issueCertificate(string $certName, string $domain, ?string $domainType): ?string { + $domain = Domain::validate($domain); $renewDate = null; foreach ($this->select($domain, $domainType) as $provider) { @@ -33,6 +34,8 @@ public function issueCertificate(string $certName, string $domain, ?string $doma public function isInstantGeneration(string $domain, ?string $domainType): bool { + $domain = Domain::validate($domain); + foreach ($this->select($domain, $domainType) as $provider) { if (!$provider->isInstantGeneration($domain, $domainType)) { return false; @@ -44,6 +47,8 @@ public function isInstantGeneration(string $domain, ?string $domainType): bool public function getCertificateStatus(string $domain, ?string $domainType): string { + $domain = Domain::validate($domain); + foreach ($this->select($domain, $domainType) as $provider) { if ($provider->isInstantGeneration($domain, $domainType)) { continue; @@ -60,6 +65,8 @@ public function getCertificateStatus(string $domain, ?string $domainType): strin public function isRenewRequired(string $domain, ?string $domainType): bool { + $domain = Domain::validate($domain); + foreach ($this->select($domain, $domainType) as $provider) { if ($provider->isRenewRequired($domain, $domainType)) { return true; @@ -71,16 +78,19 @@ public function isRenewRequired(string $domain, ?string $domainType): bool public function deleteCertificate(string $domain, ?string $domainType = null): void { + $domain = Domain::validate($domain); + foreach ($this->select($domain, $domainType) as $provider) { $provider->deleteCertificate($domain, $domainType); } } - /** @return array */ + /** + * @param string $domain Already normalized by the calling entry point. + * @return array + */ private function select(string $domain, ?string $domainType): array { - $domain = Domain::validate($domain); - if (\in_array($domainType, ['site', 'network', 'redirect'], true)) { return [$this->networkProvider]; } diff --git a/src/Cdn/Domain.php b/src/Cdn/Domain.php index 181884a..050587c 100644 --- a/src/Cdn/Domain.php +++ b/src/Cdn/Domain.php @@ -4,10 +4,19 @@ final class Domain { + /** + * Normalize a domain to its canonical form. + * + * DNS labels are case-insensitive (RFC 4343), so case carries no meaning + * and is folded rather than rejected. Callers must use the returned value: + * it is what providers and equality checks are expected to see. + */ public static function validate(string $domain): string { - if ($domain === '' || $domain !== \strtolower($domain) || \filter_var($domain, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME) === false) { - throw new \InvalidArgumentException('Domain must be a lowercase hostname without a scheme, port, path, or trailing slash.'); + $domain = \strtolower($domain); + + if ($domain === '' || \filter_var($domain, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME) === false) { + throw new \InvalidArgumentException('Domain must be a hostname without a scheme, port, path, or trailing slash.'); } return $domain; diff --git a/tests/Cdn/Certificates/Provider/ProxyTest.php b/tests/Cdn/Certificates/Provider/ProxyTest.php index ce5510e..b3f5e02 100644 --- a/tests/Cdn/Certificates/Provider/ProxyTest.php +++ b/tests/Cdn/Certificates/Provider/ProxyTest.php @@ -29,6 +29,64 @@ public function testRoutesAndAggregatesProviders(): void $this->assertContains('network:delete', $calls->getArrayCopy()); } + public function testNormalizesDomainBeforeRoutingAndDelegating(): void + { + $seen = new \ArrayObject(); + $app = $this->recorder('app', $seen); + $network = $this->recorder('network', $seen); + $custom = $this->recorder('custom', $seen); + $proxy = new Proxy('App.Example.COM', $app, $network, [$custom]); + + $proxy->deleteCertificate('APP.Example.CoM'); + + // Routed to the app provider, which only happens if the mixed-case input + // was folded before the appDomain comparison in select(). + $this->assertSame(['app:delete:app.example.com'], $seen->getArrayCopy()); + } + + public function testPassesNormalizedDomainToCustomProviders(): void + { + $seen = new \ArrayObject(); + $app = $this->recorder('app', $seen); + $proxy = new Proxy('app.example.com', $app, $app, [$this->recorder('custom', $seen)]); + + $proxy->issueCertificate('cert', 'Custom.EXAMPLE.com', null); + + $this->assertSame(['custom:issue:custom.example.com'], $seen->getArrayCopy()); + } + + /** @param \ArrayObject $seen */ + private function recorder(string $name, \ArrayObject $seen): Provider + { + return new class ($name, $seen) implements Provider { + /** @param \ArrayObject $seen */ + public function __construct(private string $name, private \ArrayObject $seen) + { + } + public function issueCertificate(string $certName, string $domain, ?string $domainType): ?string + { + $this->seen->append($this->name . ':issue:' . $domain); + return null; + } + public function isInstantGeneration(string $domain, ?string $domainType): bool + { + return false; + } + public function getCertificateStatus(string $domain, ?string $domainType): string + { + return Status::ISSUED; + } + public function isRenewRequired(string $domain, ?string $domainType): bool + { + return false; + } + public function deleteCertificate(string $domain, ?string $domainType = null): void + { + $this->seen->append($this->name . ':delete:' . $domain); + } + }; + } + public function testRejectsMissingCustomProviders(): void { $provider = $this->provider('app', Status::ISSUED, false, null, false, new \ArrayObject()); diff --git a/tests/Cdn/DomainTest.php b/tests/Cdn/DomainTest.php new file mode 100644 index 0000000..a9908cf --- /dev/null +++ b/tests/Cdn/DomainTest.php @@ -0,0 +1,35 @@ +assertSame('app.example.com', Domain::validate('APP.Example.CoM')); + $this->assertSame('app.example.com', Domain::validate('app.example.com')); + } + + /** @return array> */ + public static function malformed(): array + { + return [ + 'empty' => [''], + 'scheme' => ['https://app.example.com'], + 'port' => ['app.example.com:443'], + 'path' => ['app.example.com/certs'], + 'trailing slash' => ['app.example.com/'], + 'underscore' => ['app_example.com'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('malformed')] + public function testRejectsMalformedDomains(string $domain): void + { + $this->expectException(\InvalidArgumentException::class); + Domain::validate($domain); + } +}