<?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");
}
}