<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\library\AcmeManager;
use seekquarry\yioop\library\AcmeClient;
use seekquarry\yioop\library\AcmeChallengeStore;
use seekquarry\yioop\library\AcmeKeyStore;
use seekquarry\yioop\library\CertInstaller;
use seekquarry\yioop\library\UnitTest;
/**
* A stand-in AcmeClient that returns canned protocol responses so
* the orchestration can be exercised without a network or a
* certificate authority. Records the tokens it was told were ready
* so a test can confirm the manager published and notified each
* challenge. Its behavior is tuned by flags so failure paths can be
* provoked.
*
* @author Chris Pollett
*/
class FakeAcmeClient extends AcmeClient
{
/**
* Whether each authorization should expose an http-01
* challenge; false provokes the missing-challenge path.
* @var bool
*/
public $offer_http_challenge = true;
/**
* The status pollStatus reports for the order before finalize.
* @var string
*/
public $ready_status = "ready";
/**
* The status pollStatus reports for the order after finalize.
* @var string
*/
public $valid_status = "valid";
/**
* Tokens the manager notified ready, in order.
* @var array
*/
public $notified = [];
/**
* Tracks whether pollStatus has been called yet so the first
* call returns the pre-finalize status and the second the
* post-finalize status.
* @var int
*/
private $poll_count = 0;
/**
* No real key needed for the stand-in.
*/
public function __construct()
{
}
/**
* Does nothing; there is no directory to fetch.
*/
public function fetchDirectory()
{
}
/**
* Always reports the account registered.
* @param string $contact_email ignored
* @return bool true
*/
public function registerAccount($contact_email)
{
return true;
}
/**
* Returns a two-authorization order.
* @param array $domains the requested domains
* @return array the order
*/
public function newOrder($domains)
{
return [
"url" => "https://acme.test/order/1",
"finalize" => "https://acme.test/order/1/finalize",
"authorizations" => [
"https://acme.test/authz/a",
"https://acme.test/authz/b",
],
];
}
/**
* Returns an authorization carrying an http-01 challenge whose
* token is derived from the authorization URL, unless challenge
* offering is switched off.
* @param string $authz_url the authorization URL
* @return array the authorization
*/
public function authorization($authz_url)
{
if (!$this->offer_http_challenge) {
return ["challenges" => []];
}
$token = "tok" . substr($authz_url, -1);
return ["challenges" => [[
"type" => "http-01",
"token" => $token,
"url" => $authz_url . "/chal",
]]];
}
/**
* Returns the http-01 challenge from an authorization, matching
* the real client.
* @param array $authorization the authorization
* @return array|null the challenge or null
*/
public function httpChallenge($authorization)
{
foreach ($authorization["challenges"] ?? [] as $challenge) {
if (($challenge["type"] ?? "") === "http-01") {
return $challenge;
}
}
return null;
}
/**
* A deterministic key authorization for a token.
* @param string $token the challenge token
* @return string the key authorization
*/
public function keyAuthorization($token)
{
return $token . ".THUMBPRINT";
}
/**
* Records that a challenge was notified ready.
* @param array $challenge the challenge
* @return array a canned http result
*/
public function notifyChallengeReady($challenge)
{
$this->notified[] = $challenge["token"];
return ["status" => 200];
}
/**
* Returns the pre-finalize status on the first call and the
* post-finalize status afterward.
* @param string $url ignored
* @param int $attempts ignored
* @param int $delay ignored
* @return array the order status
*/
public function pollStatus($url, $attempts = 20, $delay = 3)
{
$this->poll_count++;
if ($this->poll_count === 1) {
return ["status" => $this->ready_status];
}
return ["status" => $this->valid_status];
}
/**
* Pretends to finalize.
* @param array $order ignored
* @param string $csr_der ignored
* @return array a canned order
*/
public function finalizeOrder($order, $csr_der)
{
return ["status" => "processing"];
}
/**
* Returns a placeholder certificate chain.
* @param array $order ignored
* @return string a placeholder PEM chain
*/
public function downloadCertificate($order)
{
return "-----BEGIN CERTIFICATE-----\nFAKE\n" .
"-----END CERTIFICATE-----\n";
}
}
/**
* An AcmeManager whose protocol client is the fake above, so the
* orchestration runs offline.
*
* @author Chris Pollett
*/
class TestableAcmeManager extends AcmeManager
{
/**
* The fake client to hand back from makeClient.
* @var FakeAcmeClient
*/
public $fake_client;
/**
* Returns the injected fake client instead of a real one.
* @param string $account_key ignored
* @return FakeAcmeClient the fake client
*/
protected function makeClient($account_key)
{
return $this->fake_client;
}
}
/**
* Tests for AcmeManager: the obtainCertificate orchestration over a
* fake protocol client, with real on-disk challenge, key, and
* installer stores pointed at a per-test scratch directory. Cases
* cover the happy path (issue and install, tokens published and
* notified then cleared), an authorization missing its http-01
* challenge, and an order that never reaches the ready state, with
* token cleanup verified on the failure paths.
*
* @author Chris Pollett
*/
class AcmeManagerTest extends UnitTest
{
/**
* Per-test scratch directory.
* @var string
*/
private $dir;
/**
* The challenge store shared with the manager under test.
* @var AcmeChallengeStore
*/
private $challenge_store;
/**
* Makes a fresh scratch directory and the stores rooted in it.
*/
public function setUp()
{
$this->dir = sys_get_temp_dir() . "/acme_mgr_test_" .
getmypid() . "_" . uniqid();
@mkdir($this->dir, 0700, true);
$this->challenge_store = new AcmeChallengeStore(
$this->dir . "/challenges");
}
/**
* Removes the scratch directory and its contents.
*/
public function tearDown()
{
$this->removeTree($this->dir);
}
/**
* Recursively removes a directory tree.
* @param string $path file or directory to remove
*/
private function removeTree($path)
{
if (is_dir($path)) {
foreach (glob($path . "/*") as $child) {
$this->removeTree($child);
}
@rmdir($path);
} else if (is_file($path)) {
@unlink($path);
}
}
/**
* Builds a manager wired to the fake client and the scratch
* stores.
* @param FakeAcmeClient $client the fake protocol client
* @return TestableAcmeManager the manager under test
*/
private function makeManager($client)
{
$key_store = new AcmeKeyStore($this->dir . "/acme");
$installer = new CertInstaller(
$this->dir . "/server.fullchain.pem",
$this->dir . "/server.key");
$manager = new TestableAcmeManager(
"https://acme.test/dir", "admin@example.test",
$key_store, $this->challenge_store, $installer, null);
$manager->fake_client = $client;
return $manager;
}
/**
* The happy path issues and installs a certificate, notifies
* both challenges, and leaves no tokens behind.
*/
public function happyPathIssuesAndInstallsTestCase()
{
$client = new FakeAcmeClient();
$manager = $this->makeManager($client);
$ok = $manager->obtainCertificate(
["pollett.org", "www.pollett.org"]);
$this->assertTrue($ok,
"obtainCertificate succeeds on the happy path");
$this->assertEqual(2, count($client->notified),
"both challenges were notified ready");
$this->assertTrue(
is_file($this->dir . "/server.fullchain.pem"),
"the certificate chain was installed");
$this->assertTrue(is_file($this->dir . "/server.key"),
"the private key was installed");
}
/**
* After a successful run the published challenge tokens are
* cleared from the store.
*/
public function tokensClearedAfterSuccessTestCase()
{
$client = new FakeAcmeClient();
$manager = $this->makeManager($client);
$manager->obtainCertificate(["pollett.org"]);
$this->assertTrue(
$this->challenge_store->get("toka") === null &&
$this->challenge_store->get("tokb") === null,
"published tokens are cleared after success");
}
/**
* An authorization with no http-01 challenge fails the run and
* leaves no tokens served.
*/
public function missingChallengeFailsTestCase()
{
$client = new FakeAcmeClient();
$client->offer_http_challenge = false;
$manager = $this->makeManager($client);
$ok = $manager->obtainCertificate(["pollett.org"]);
$this->assertFalse($ok,
"a missing http-01 challenge fails the run");
$this->assertTrue($this->challenge_store->get("toka") === null,
"no token is left served after the failure");
}
/**
* An order that never becomes ready fails the run, and the
* tokens published while trying are cleared.
*/
public function orderNotReadyFailsAndClearsTestCase()
{
$client = new FakeAcmeClient();
$client->ready_status = "pending";
$manager = $this->makeManager($client);
$ok = $manager->obtainCertificate(["pollett.org"]);
$this->assertFalse($ok,
"an order that never readies fails the run");
$this->assertTrue(
$this->challenge_store->get("toka") === null &&
$this->challenge_store->get("tokb") === null,
"tokens published during the attempt are cleared");
}
/**
* An empty domain list is rejected before any protocol work.
*/
public function emptyDomainsRejectedTestCase()
{
$client = new FakeAcmeClient();
$manager = $this->makeManager($client);
$this->assertFalse($manager->obtainCertificate([]),
"an empty domain list is rejected");
}
}