/ tests / CertInstallerTest.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\CertInstaller;
use seekquarry\yioop\library\UnitTest;

/**
 * A minimal listener stand-in exposing the is_secure flag and
 * resource() method CertInstaller::reloadListener reads, backed by
 * a real loopback socket so stream_context_set_option has a genuine
 * stream to act on.
 *
 * @author Chris Pollett
 */
class FakeListener
{
    /**
     * Whether this listener is the secure (TLS) one.
     * @var bool
     */
    public $is_secure;
    /**
     * Underlying socket resource.
     * @var resource
     */
    private $socket;
    /**
     * @param bool $is_secure whether to mark this listener secure
     * @param resource $socket a stream resource for the listener
     */
    public function __construct($is_secure, $socket)
    {
        $this->is_secure = $is_secure;
        $this->socket = $socket;
    }
    /**
     * Returns the underlying socket resource.
     * @return resource the socket
     */
    public function resource()
    {
        return $this->socket;
    }
}

/**
 * A WebSite stand-in whose listeners() returns a fixed list, enough
 * for CertInstaller::reloadListener to iterate.
 *
 * @author Chris Pollett
 */
class FakeWebSite
{
    /**
     * The listener list reloadListener iterates.
     * @var array
     */
    private $listener_list;
    /**
     * @param array $listener_list listeners to expose
     */
    public function __construct($listener_list)
    {
        $this->listener_list = $listener_list;
    }
    /**
     * Returns the fixed listener list.
     * @return array the listeners
     */
    public function listeners()
    {
        return $this->listener_list;
    }
}

/**
 * Tests for CertInstaller: atomic file writing with correct
 * permissions, the combined install(), and reloadListener's
 * selection of secure listeners. The certificate "contents" are
 * arbitrary strings here, since these cases test the installer's
 * file and listener handling rather than certificate validity; the
 * end-to-end proof that an in-place context update makes the server
 * present a new certificate is covered separately by the cert
 * hot-reload check against the real listener.
 *
 * @author Chris Pollett
 */
class CertInstallerTest extends UnitTest
{
    /**
     * Per-test scratch directory for cert and key files.
     * @var string
     */
    private $dir;
    /**
     * Makes a fresh empty scratch directory each test.
     */
    public function setUp()
    {
        $this->dir = sys_get_temp_dir() . "/cert_installer_test_" .
            getmypid() . "_" . uniqid();
        @mkdir($this->dir, 0700, true);
    }
    /**
     * Removes the scratch directory and its files.
     */
    public function tearDown()
    {
        $this->removeTree($this->dir);
    }
    /**
     * Recursively removes a directory and everything under it, so
     * cases that create nested directories clean up fully.
     *
     * @param string $path directory or file 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);
        }
    }
    /**
     * writeFiles writes both the chain and the key with the
     * expected contents.
     */
    public function writeFilesWritesBothTestCase()
    {
        $cert_path = $this->dir . "/server.fullchain.pem";
        $key_path = $this->dir . "/server.key";
        $installer = new CertInstaller($cert_path, $key_path);
        $ok = $installer->writeFiles("CHAIN-BYTES", "KEY-BYTES");
        $this->assertTrue($ok, "writeFiles reports success");
        $this->assertEqual("CHAIN-BYTES",
            file_get_contents($cert_path),
            "certificate chain written with expected contents");
        $this->assertEqual("KEY-BYTES",
            file_get_contents($key_path),
            "private key written with expected contents");
    }
    /**
     * The private key file is created mode 0600 so only the owner
     * can read it.
     */
    public function keyFileIsPrivateTestCase()
    {
        $cert_path = $this->dir . "/server.fullchain.pem";
        $key_path = $this->dir . "/server.key";
        $installer = new CertInstaller($cert_path, $key_path);
        $installer->writeFiles("CHAIN", "SECRETKEY");
        $perms = fileperms($key_path) & 0777;
        $this->assertEqual(0600, $perms,
            "private key is mode 0600");
    }
    /**
     * writeFiles creates a missing destination directory rather
     * than failing.
     */
    public function writeCreatesMissingDirTestCase()
    {
        $sub = $this->dir . "/nested/security";
        $cert_path = $sub . "/server.fullchain.pem";
        $key_path = $sub . "/server.key";
        $installer = new CertInstaller($cert_path, $key_path);
        $ok = $installer->writeFiles("CHAIN", "KEY");
        $this->assertTrue($ok && is_file($cert_path),
            "missing directory is created and files written");
    }
    /**
     * A rewrite replaces the previous contents, modeling a renewal
     * landing over an existing certificate.
     */
    public function rewriteReplacesContentsTestCase()
    {
        $cert_path = $this->dir . "/server.fullchain.pem";
        $key_path = $this->dir . "/server.key";
        $installer = new CertInstaller($cert_path, $key_path);
        $installer->writeFiles("OLD-CHAIN", "OLD-KEY");
        $installer->writeFiles("NEW-CHAIN", "NEW-KEY");
        $this->assertEqual("NEW-CHAIN",
            file_get_contents($cert_path),
            "rewrite replaces the certificate chain");
        $this->assertEqual("NEW-KEY",
            file_get_contents($key_path),
            "rewrite replaces the private key");
    }
    /**
     * reloadListener updates only secure listeners and returns
     * their count, leaving plain listeners alone.
     */
    public function reloadSelectsSecureListenersTestCase()
    {
        $cert_path = $this->dir . "/server.fullchain.pem";
        $key_path = $this->dir . "/server.key";
        $installer = new CertInstaller($cert_path, $key_path);
        $installer->writeFiles("CHAIN", "KEY");
        $secure_socket = stream_socket_server("tcp://127.0.0.1:0",
            $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
            stream_context_create([]));
        $plain_socket = stream_socket_server("tcp://127.0.0.1:0",
            $errno2, $errstr2,
            STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
            stream_context_create([]));
        $web_site = new FakeWebSite([
            new FakeListener(false, $plain_socket),
            new FakeListener(true, $secure_socket),
        ]);
        $count = $installer->reloadListener($web_site);
        $this->assertEqual(1, $count,
            "exactly one secure listener is reloaded");
        $set = stream_context_get_params($secure_socket);
        $this->assertEqual($cert_path,
            $set["options"]["ssl"]["local_cert"] ?? "",
            "secure listener context now points at the new cert");
        $this->assertEqual($key_path,
            $set["options"]["ssl"]["local_pk"] ?? "",
            "secure listener context now points at the new key");
        $plain_params = stream_context_get_params($plain_socket);
        $this->assertTrue(
            empty($plain_params["options"]["ssl"]["local_cert"]),
            "plain listener context is left untouched");
        @fclose($secure_socket);
        @fclose($plain_socket);
    }
    /**
     * reloadListener returns zero when there is no secure listener,
     * and tolerates a null web site.
     */
    public function reloadNoSecureListenerTestCase()
    {
        $installer = new CertInstaller($this->dir . "/c.pem",
            $this->dir . "/k.pem");
        $plain_socket = stream_socket_server("tcp://127.0.0.1:0",
            $errno, $errstr);
        $web_site = new FakeWebSite([
            new FakeListener(false, $plain_socket),
        ]);
        $this->assertEqual(0,
            $installer->reloadListener($web_site),
            "no secure listener means zero reloaded");
        $this->assertEqual(0, $installer->reloadListener(null),
            "a null web site reloads nothing");
        @fclose($plain_socket);
    }
    /**
     * install writes the files even when no web site is supplied,
     * the case where issuance runs separately from the server.
     */
    public function installWithoutWebSiteWritesFilesTestCase()
    {
        $cert_path = $this->dir . "/server.fullchain.pem";
        $key_path = $this->dir . "/server.key";
        $installer = new CertInstaller($cert_path, $key_path);
        $ok = $installer->install("CHAIN", "KEY");
        $this->assertTrue($ok && is_file($cert_path) &&
            is_file($key_path),
            "install writes both files with no web site supplied");
    }
}
X