/ tests / AcmeManagerTest.php
<?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");
    }
}
X