Add PHP unit tests and page tests.

Run "composer install", then "composer test".
This commit is contained in:
tim
2026-09-26 03:48:09 +02:00
parent 848c447c5b
commit ed959888b4
42 changed files with 5522 additions and 0 deletions
+5
View File
@@ -3,3 +3,8 @@
# Old upload directory; uploads now go to UPLOAD_DIR outside the web root
graphics/tmp/
# Test tools (composer install, npm install)
/vendor/
/node_modules/
/.phpunit.cache/
+20
View File
@@ -0,0 +1,20 @@
{
"name": "xxx/xxx",
"description": "x.x.x, a site of hundred-word stories",
"type": "project",
"license": "proprietary",
"require-dev": {
"phpunit/phpunit": "^13.3"
},
"autoload-dev": {
"psr-4": {
"Xxx\\Tests\\": "tests/php/"
}
},
"config": {
"sort-packages": true
},
"scripts": {
"test": "phpunit"
}
}
Generated
+1937
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/php/bootstrap.php"
cacheDirectory=".phpunit.cache"
colors="true"
stderr="true"
executionOrder="depends,defects"
failOnWarning="true"
failOnNotice="true"
failOnDeprecation="true"
failOnRisky="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true">
<!-- stderr="true" keeps standard output empty, so that header(),
setcookie() and session_start() work as they would in a request -->
<php>
<!-- The bootstrap sends the error log to a file; a fatal error
still shows on the terminal -->
<ini name="display_errors" value="stderr"/>
</php>
<testsuites>
<testsuite name="unit">
<directory>tests/php/Unit</directory>
</testsuite>
<testsuite name="pages">
<directory>tests/php/Pages</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">.</directory>
</include>
<exclude>
<directory>vendor</directory>
<directory>node_modules</directory>
<directory>tests</directory>
<directory>lib/htmlpurifier-4.19.1</directory>
<file>lib/smartypants.inc.php</file>
</exclude>
</source>
</phpunit>
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
// publish/addeditor.php
class AddEditorTest extends PageTestCase {
private string $token;
protected function setUp(): void {
parent::setUp();
$this->setTables(array('editors' => array(array('username' => 'ed', 'password' => 'x', 'email' => ''))));
$this->token = $this->loginAs();
}
private function add(array $fields = array()): array {
return $this->post('/publish/addeditor', $fields + array('adduser' => '1', 'username' => 'newbie', 'password' => 'pass word',
'email' => 'new@example.org', 'csrf_token' => $this->token));
}
public function testVisitorsAreSentToTheLogin(): void {
$this->cookies = array();
$this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/addeditor'));
$this->assertRedirect('https://xxx.org.za/publish/login', $this->add(array('csrf_token' => '')));
$this->assertCount(1, $this->tables()['editors']);
}
public function testPlainHttpIsSentToHttps(): void {
$this->assertRedirect('https://xxx.org.za/publish/addeditor', $this->get('/publish/addeditor', array('X-Test-Plain' => '1')));
}
public function testTheFormHasTheToken(): void {
$this->assertSame($this->token, $this->csrfToken($this->get('/publish/addeditor')['body']));
}
public function testAnEditorIsAddedWithAPasswordHash(): void {
$answer = $this->add();
$this->assertStringContainsString('<p>Added editor newbie</p>', $answer['body']);
$row = $this->tables()['editors'][1];
$this->assertSame(array('newbie', 'new@example.org'), array($row['username'], $row['email']));
$this->assertTrue(password_verify('pass word', $row['password']));
}
public function testTheTokenIsChecked(): void {
$answer = $this->add(array('csrf_token' => 'wrong'));
$this->assertStringContainsString('Your session has expired. Please try again.', $answer['body']);
$this->assertCount(1, $this->tables()['editors']);
}
public function testAnExistingUsernameIsRefused(): void {
$answer = $this->add(array('username' => 'ed'));
$this->assertStringContainsString('An editor with that username already exists.', $answer['body']);
$this->assertSame('x', $this->tables()['editors'][0]['password']);
}
public function testAHashThatDoesNotFitIsNotKept(): void {
$this->setTables(array('editors' => array()), 40);
$answer = $this->add();
$this->assertStringContainsString('the editors.password column is too short', $answer['body']);
$this->assertSame(array(), $this->tables()['editors']);
}
public function testTheUsernameIsEscaped(): void {
$body = $this->add(array('username' => '<b>x</b>'))['body'];
$this->assertStringContainsString('Added editor &lt;b&gt;x&lt;/b&gt;', $body);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
// publish/edit.php, for editors
class EditTest extends PageTestCase {
private string $token;
protected function setUp(): void {
parent::setUp();
$this->setTables(array(
'xxx' => array(array('id' => 1, 'title' => 'Live title', 'date' => '2021-01-01 10:00:00'),
array('id' => 2, 'title' => 'Other live', 'date' => '2021-01-02 10:00:00')),
'queue' => array(array('id' => 5, 'title' => 'Queued title', 'date' => '2021-02-01 10:00:00')),
));
$this->token = $this->loginAs();
}
private function story(array $fields): array {
return $fields + array('title' => 'Changed', 'author' => 'Ann', 'story' => '<p>New text.</p>', 'csrf_token' => $this->token);
}
public function testVisitorsAreSentToTheLogin(): void {
$this->cookies = array();
$this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/edit'));
}
public function testPlainHttpIsSentToHttps(): void {
$this->assertRedirect('https://xxx.org.za/publish/edit?id=1', $this->get('/publish/edit?id=1', array('X-Test-Plain' => '1')));
}
public function testTheQueueIsShownFirst(): void {
$body = $this->get('/publish/edit')['body'];
$this->assertStringContainsString('Queued title', $body);
$this->assertStringNotContainsString('Live title', $body);
$this->assertStringContainsString('<a href="edit?queue=0">click here</a>', $body);
}
public function testTheLiveStoriesCanBeChosen(): void {
$body = $this->get('/publish/edit?queue=0')['body'];
$this->assertStringContainsString('Live title', $body);
$this->assertStringContainsString('Other live', $body);
$this->assertStringNotContainsString('Queued title', $body);
}
public function testAStoryOpensInTheForm(): void {
$body = $this->get('/publish/edit?id=1&queue=0')['body'];
$this->assertStringContainsString('<input type="text" id="title" name="title" maxlength="150" value="Live title" />', $body);
$this->assertStringContainsString('<input type="hidden" id="editor" name="editor" value="ed" />', $body);
$this->assertStringContainsString('value="delete"', $body);
$this->assertSame($this->token, $this->csrfToken($body));
}
public function testEditorsUpdateALiveStory(): void {
$page = $this->post('/publish/edit', $this->story(array('action' => 'post', 'id' => '1', 'queue' => '0')));
$this->assertSame(200, $page['status']);
$this->assertSame('Changed', $this->tables()['xxx'][0]['title']);
$this->assertSame(array(), $this->mails());
}
public function testPostingFromTheQueueMovesTheStory(): void {
$this->post('/publish/edit', $this->story(array('action' => 'post', 'id' => '5', 'queue' => '1')));
$tables = $this->tables();
$this->assertSame(array(), $tables['queue']);
$this->assertSame(array('Live title', 'Other live', 'Changed'), array_column($tables['xxx'], 'title'));
}
public function testSavingKeepsTheStoryInTheQueue(): void {
$this->post('/publish/edit', $this->story(array('action' => 'save', 'id' => '5', 'queue' => '1', 'comments' => 'Needs work')));
$row = $this->tables()['queue'][0];
$this->assertSame(array('5', 'Changed', 'Needs work'), array($row['id'], $row['title'], $row['comments']));
}
public function testDeletingNeedsTheToken(): void {
$page = $this->post('/publish/edit', array('action' => 'delete', 'id' => '1', 'queue' => '0', 'csrf_token' => 'wrong'));
$this->assertStringContainsString('Your session has expired.', $page['body']);
$this->assertCount(2, $this->tables()['xxx']);
$this->assertSame(array(), $this->queries('/^DELETE/'));
}
public function testDeletingAStory(): void {
$page = $this->post('/publish/edit', array('action' => 'delete', 'id' => '1', 'queue' => '0', 'csrf_token' => $this->token));
$this->assertStringContainsString('Article has been deleted.', $page['body']);
$this->assertSame(array('Other live'), array_column($this->tables()['xxx'], 'title'));
}
public function testDeletingCannotBeDoneByGet(): void {
$this->get('/publish/edit?action=delete&id=1&queue=0&csrf_token=' . $this->token);
$this->assertCount(2, $this->tables()['xxx']);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
use Xxx\Tests\Support\TestCase;
// feed.php and display.php
class FeedAndImageTest extends PageTestCase {
protected function setUp(): void {
parent::setUp();
$this->setTables(array(
'xxx' => array(
array('id' => 1, 'title' => 'Tom & "Jerry"', 'author' => 'Ann <ann>', 'date' => '2021-01-03 10:00:00',
'story' => "<p>Wait&hellip; a <strong>bold</strong>\x01 move &amp; more.</p>"),
array('id' => 2, 'title' => 'Picture', 'date' => '2021-01-02 10:00:00', 'story' => '', 'img' => TestCase::png(), 'mime' => 'text/html'),
array('id' => 3, 'title' => 'Secret', 'date' => '2021-01-01 10:00:00', 'img' => TestCase::png(), 'mime' => 'image/png', 'hidden' => '1'),
array('id' => 4, 'title' => 'Not an image', 'img' => '<script>alert(1)</script>', 'mime' => 'image/png'),
),
'queue' => array(
array('id' => 9, 'title' => 'Waiting', 'img' => TestCase::png(), 'mime' => 'image/png'),
),
));
}
public function testTheFeedIsValidRss(): void {
$feed = $this->get('/feed');
$this->assertSame('application/rss+xml', $this->header($feed, 'Content-Type'));
$xml = simplexml_load_string($feed['body']);
$this->assertNotFalse($xml, $feed['body']);
$items = $xml->channel->item;
// The front page, then the visible stories, newest first
$this->assertSame(array('Front Page', 'Tom & "Jerry"', 'Picture', 'Not an image'),
array_map('strval', iterator_to_array($xml->xpath('/rss/channel/item/title'), FALSE)));
$this->assertSame('http://xxx.org.za/article?id=1', (string)$items[1]->link);
$this->assertSame('Wait… a bold move & more.', (string)$items[1]->description);
$this->assertSame('Ann <ann>', (string)$items[1]->children('http://purl.org/dc/elements/1.1/')->creator);
$this->assertSame('Image: Picture', (string)$items[2]->description);
$this->assertSame(date('D, d M Y H:i:s O', strtotime('2021-01-03 10:00:00')), (string)$xml->channel->lastBuildDate);
}
public function testAnImageIsServedWithItsRealType(): void {
$image = $this->get('/display?id=2');
$this->assertSame(200, $image['status']);
$this->assertSame(TestCase::png(), $image['body']);
$this->assertSame('image/png', $this->header($image, 'Content-Type'));
$this->assertSame('nosniff', $this->header($image, 'X-Content-Type-Options'));
$this->assertSame((string)strlen(TestCase::png()), $this->header($image, 'Content-Length'));
}
public function testDataThatIsNotAnImageIsNotServed(): void {
$image = $this->get('/display?id=4');
$this->assertSame(404, $image['status']);
$this->assertStringNotContainsString('script', $image['body']);
}
public function testAMissingImageIsNotFound(): void {
$this->assertSame(404, $this->get('/display?id=99')['status']);
$this->assertSame(404, $this->get('/display?id=1')['status']);
}
public function testHiddenImagesAreForEditorsOnly(): void {
$this->assertSame(404, $this->get('/display?id=3')['status']);
$this->loginAs();
$this->assertSame(200, $this->get('/display?id=3')['status']);
}
public function testQueuedImagesNeedPermission(): void {
$this->assertSame(401, $this->get('/display?id=9&queue=1')['status']);
$this->startSession(array('allow_queue_image' => '8'));
$this->assertSame(401, $this->get('/display?id=9&queue=1')['status']);
$this->startSession(array('allow_queue_image' => '9'));
$this->assertSame(200, $this->get('/display?id=9&queue=1')['status']);
$this->loginAs();
$this->assertSame(200, $this->get('/display?id=9&queue=1')['status']);
}
public function testThePendingUploadIsShownToItsSession(): void {
$name = bin2hex(random_bytes(16));
mkdir(self::$state . '/tmp/uploads', 0700);
file_put_contents(self::$state . "/tmp/uploads/$name", TestCase::png());
$this->assertSame(404, $this->get('/display?pending=x')['status']);
$this->startSession(array('pending_upload' => $name));
$image = $this->get('/display?pending=' . substr($name, 0, 8));
$this->assertSame(200, $image['status']);
$this->assertSame(TestCase::png(), $image['body']);
$this->assertSame('private, no-store', $this->header($image, 'Cache-Control'));
}
public function testAPendingUploadMustBeAnImage(): void {
$name = bin2hex(random_bytes(16));
mkdir(self::$state . '/tmp/uploads', 0700);
file_put_contents(self::$state . "/tmp/uploads/$name", '<html>This is not an image at all.</html>');
$this->startSession(array('pending_upload' => $name));
$this->assertSame(404, $this->get('/display?pending=x')['status']);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
// The public pages: the front page, the archive, a story and about
class FrontPagesTest extends PageTestCase {
protected function setUp(): void {
parent::setUp();
$this->setTables(array('xxx' => array(
array('id' => 1, 'title' => 'Newest', 'author' => 'Ann', 'date' => '2021-01-03 10:00:00', 'story' => '<p>First <em>story</em>.</p>'),
array('id' => 2, 'title' => 'Middle', 'author' => 'Bo', 'date' => '2021-01-02 10:00:00', 'story' => '<p onclick="x()">Second.</p>'),
array('id' => 3, 'title' => 'Secret', 'author' => 'Cy', 'date' => '2021-01-01 10:00:00', 'story' => '<p>Hidden.</p>', 'hidden' => '1'),
)));
}
public function testFrontPageListsTheVisibleStories(): void {
$page = $this->get('/');
$this->assertSame(200, $page['status']);
$this->assertStringContainsString('<a href="/article?id=1">Newest</a>', $page['body']);
$this->assertStringContainsString('<a href="/article?id=2">Middle</a>', $page['body']);
$this->assertStringNotContainsString('Secret', $page['body']);
$this->assertStringNotContainsString('onclick', $page['body']);
$this->assertStringEndsWith("</html>\n", $page['body']);
}
public function testPagesAreNotCached(): void {
$page = $this->get('/');
$this->assertStringContainsString('no-store', implode(', ', $page['headers']['cache-control']));
}
public function testTheSessionCookieIsSecure(): void {
$cookie = $this->header($this->get('/'), 'Set-Cookie');
$this->assertMatchesRegularExpression('/^PHPSESSID=[0-9a-z,-]+;/', $cookie);
foreach (array('path=/', 'domain=.xxx.org.za', 'secure', 'HttpOnly', 'SameSite=Lax') as $flag)
$this->assertStringContainsString("; $flag", $cookie);
}
public function testPlainHttpStartsNoSession(): void {
$page = $this->get('/', array('X-Test-Plain' => '1'));
$this->assertSame(200, $page['status']);
$this->assertArrayNotHasKey('set-cookie', $page['headers']);
}
public function testNoAnalyticsOrEditorLinksForVisitors(): void {
$body = $this->get('/')['body'];
$this->assertStringNotContainsString('google-analytics', $body);
$this->assertStringNotContainsString('/publish/logout', $body);
$this->assertStringNotContainsString('/publish/addeditor', $body);
}
public function testEditorsSeeHiddenStoriesAndTheirLinks(): void {
$token = $this->loginAs();
$body = $this->get('/')['body'];
$this->assertStringContainsString('Secret', $body);
$this->assertStringContainsString('<a href="/publish/logout?token=' . $token . '">Logout</a>', $body);
$this->assertStringContainsString('<a href="/publish/addeditor">Add editor</a>', $body);
}
public function testTheArticlePageShowsOneStory(): void {
$body = $this->get('/article?id=1')['body'];
$this->assertStringContainsString('<p>First <em>story</em>.</p>', $body);
$this->assertStringNotContainsString('Middle', $body);
}
public function testAHiddenStoryIsNotShown(): void {
$body = $this->get('/article?id=3')['body'];
$this->assertStringNotContainsString('Hidden.', $body);
}
public function testSearchFindsStories(): void {
$body = $this->get('/article?search=second')['body'];
$this->assertStringContainsString('Middle', $body);
$this->assertStringNotContainsString('Newest', $body);
$this->assertSame(array('%second%'), array_slice($this->queries('/LIKE/')[0]['params'], 0, 1));
}
public function testTheSearchTermIsEscaped(): void {
$body = $this->get('/article?search=' . rawurlencode('"><script>x</script>'))['body'];
$this->assertStringNotContainsString('<script>x</script>', $body);
}
public function testThePageRemembersWhereTheVisitorWas(): void {
$this->get('/article?id=2');
$this->assertSame('/article?id=2', $this->session()['referrer']);
}
public function testTheAboutPageLoads(): void {
$page = $this->get('/about');
$this->assertSame(200, $page['status']);
$this->assertStringContainsString('</html>', $page['body']);
}
public function testTheTestsAreNotServed(): void {
$this->assertSame(404, $this->get('/tests/php/fixtures/test.env')['status']);
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
// publish/login.php, publish/logout.php and persistent logins
class LoginTest extends PageTestCase {
protected function setUp(): void {
parent::setUp();
$this->setTables(array('editors' => array(
array('username' => 'ed', 'password' => password_hash('secret', PASSWORD_BCRYPT, array('cost' => 4)), 'email' => 'ed@example.org'),
)));
}
// Open the login form, then send it; returns the answer
private function login(string $password = 'secret', array $fields = array()): array {
$token = $this->csrfToken($this->get('/publish/login')['body']);
return $this->post('/publish/login', $fields + array('login' => '1', 'username' => 'ed', 'password' => $password,
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
}
/*
* After a login, the site goes back to the last page that the session
* showed. With the login form that is the login page itself, which
* sends an editor on to the edit page.
*/
private function assertLoggedIn(array $answer): void {
$this->assertRedirect('https://' . $this->host() . '/publish/login', $answer);
$this->assertRedirect('https://xxx.org.za/publish/edit', $this->get('/publish/login'));
}
private function host(): string {
return parse_url(self::$base, PHP_URL_HOST) . ':' . parse_url(self::$base, PHP_URL_PORT);
}
public function testPlainHttpIsSentToHttps(): void {
$this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/login', array('X-Test-Plain' => '1')));
}
public function testTheFormHasATokenAndTheCaptcha(): void {
$body = $this->get('/publish/login')['body'];
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($body));
$this->assertStringContainsString('data-sitekey="test-site-key"', $body);
}
public function testTheRightPasswordLogsIn(): void {
$this->get('/publish/login');
$before = $this->cookies['PHPSESSID'];
$this->assertLoggedIn($this->login('secret', array('remember' => NULL)));
// A new session id, and a new CSRF token with it
$this->assertNotSame($before, $this->cookies['PHPSESSID']);
$session = $this->session();
$this->assertSame('ed', $session['editor']);
$this->assertArrayNotHasKey('csrf_token', $session);
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
}
public function testTheLoginGoesBackToTheLastPage(): void {
$token = $this->csrfToken($this->get('/publish/login')['body']);
$this->get('/article?id=1');
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
$this->assertRedirect('https://' . $this->host() . '/article?id=1', $answer);
$this->assertArrayNotHasKey('referrer', $this->session());
}
public function testWithoutALastPageTheLoginGoesToTheEditPage(): void {
$token = $this->csrfToken($this->get('/publish/login')['body']);
$this->startSession(array('csrf_token' => $token));
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
$this->assertRedirect('https://xxx.org.za/publish/edit', $answer);
}
public function testTheWrongPasswordIsDenied(): void {
$answer = $this->login('wrong');
$this->assertSame(200, $answer['status']);
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
$this->assertArrayNotHasKey('editor', $this->session());
}
public function testAnUnknownEditorIsDenied(): void {
$answer = $this->login('secret', array('username' => 'nobody'));
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
}
public function testTheTokenIsChecked(): void {
$this->get('/publish/login');
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
'csrf_token' => 'wrong', 'g-recaptcha-response' => 'pass'));
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
$this->assertSame(array(), $this->queries('/FROM editors/'));
}
public function testTheCaptchaIsChecked(): void {
$answer = $this->login('secret', array('g-recaptcha-response' => 'fail'));
$this->assertStringContainsString('Please complete the captcha.', $answer['body']);
$this->assertSame(array(), $this->queries('/FROM editors/'));
}
public function testFiveFailuresBlockTheLogin(): void {
for ($i = 0; $i < 5; $i++)
$this->login('wrong');
$answer = $this->login('secret');
$this->assertStringContainsString('Too many failed logins. Please try again in 15 minutes.', $answer['body']);
$this->assertArrayNotHasKey('editor', $this->session());
}
public function testTheBlockIsPerAddress(): void {
for ($i = 0; $i < 5; $i++)
$this->login('wrong');
$token = $this->csrfToken($this->get('/publish/login')['body']);
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', 'csrf_token' => $token,
'g-recaptcha-response' => 'pass'), array('X-Test-Address' => '198.51.100.7'));
$this->assertLoggedIn($answer);
}
public function testAnOldHashIsReplacedAtLogin(): void {
$this->setTables(array('editors' => array(array('username' => 'ed', 'password' => crypt('secret', '$1$abcdefgh$'), 'email' => ''))));
$this->assertLoggedIn($this->login());
$hash = $this->tables()['editors'][0]['password'];
$this->assertFalse(password_needs_rehash($hash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT));
$this->assertTrue(password_verify('secret', $hash));
}
public function testAnEditorWhoIsLoggedInGoesToTheEditPage(): void {
$this->loginAs();
$this->assertRedirect('https://xxx.org.za/publish/edit', $this->get('/publish/login'));
}
public function testRememberMeKeepsTheEditorLoggedIn(): void {
$this->login('secret', array('remember' => 'on'));
$this->assertMatchesRegularExpression('/^ed\|[0-9a-f]{64}$/', $this->cookies['PersistentAuth']);
$key = explode('|', $this->cookies['PersistentAuth'])[1];
$this->assertSame(array(hash('sha256', $key)), array_column($this->tables()['persistent_sessions'], 'session'));
// A new browser session with only the cookie
unset($this->cookies['PHPSESSID']);
$body = $this->get('/')['body'];
$this->assertStringContainsString('/publish/logout?token=', $body);
$this->assertSame('ed', $this->session()['editor']);
}
public function testAWrongPersistentKeyDoesNotLogIn(): void {
$this->setTables(array('persistent_sessions' => array(
array('username' => 'ed', 'session' => hash('sha256', 'right'), 'created' => gmdate('Y-m-d H:i:s')))));
$this->cookies['PersistentAuth'] = 'ed|wrong';
$body = $this->get('/')['body'];
$this->assertStringNotContainsString('/publish/logout', $body);
}
public function testLogoutNeedsTheToken(): void {
$this->loginAs();
$this->assertRedirect('https://xxx.org.za/', $this->get('/publish/logout?token=wrong'));
$this->assertSame('ed', $this->session()['editor']);
}
public function testLogoutEndsTheSessionAndThePersistentLogin(): void {
$this->setTables(array('persistent_sessions' => array(
array('username' => 'ed', 'session' => hash('sha256', 'key'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ed', 'session' => hash('sha256', 'other device'), 'created' => gmdate('Y-m-d H:i:s')))));
$token = $this->loginAs('ed', array('referrer' => '/article?id=2'));
$this->cookies['PersistentAuth'] = 'ed|key';
$session = self::$state . '/sessions/sess_' . $this->cookies['PHPSESSID'];
$answer = $this->get('/publish/logout?token=' . $token);
$this->assertRedirect('https://' . $this->host() . '/article?id=2', $answer);
$this->assertFileDoesNotExist($session);
$this->assertArrayNotHasKey('PHPSESSID', $this->cookies);
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
$this->assertSame(array(hash('sha256', 'other device')), array_column($this->tables()['persistent_sessions'], 'session'));
}
public function testLogoutDoesNotGoBackToTheLogoutPage(): void {
$token = $this->loginAs('ed', array('referrer' => '/publish/logout'));
$this->assertRedirect('/', $this->get('/publish/logout?token=' . $token));
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
// Sending a story from publish/ as a visitor
class SubmitTest extends PageTestCase {
protected function setUp(): void {
parent::setUp();
$this->setTables(array('xxx' => array(array('id' => 1, 'title' => 'Live'))));
}
private function story(array $fields = array()): array {
return $fields + array('title' => 'My story', 'author' => 'Vi', 'email' => 'vi@example.org',
'story' => '<p>Once -- upon a "time"<script>x()</script>.</p>');
}
public function testPlainHttpIsSentToHttps(): void {
$this->assertRedirect('https://xxx.org.za/publish', $this->get('/publish', array('X-Test-Plain' => '1')));
}
public function testTheFormHasATokenAndTheCaptcha(): void {
$page = $this->get('/publish');
$this->assertSame(200, $page['status']);
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($page['body']));
$this->assertStringContainsString('<div class="g-recaptcha" data-sitekey="test-site-key"></div>', $page['body']);
$this->assertStringContainsString('https://www.google.com/recaptcha/api.js', $page['body']);
$this->assertStringContainsString('<script type="text/javascript" src="../publish/editor.js"></script>', $page['body']);
}
public function testEditorsGetNoCaptcha(): void {
$this->loginAs();
$body = $this->get('/publish')['body'];
$this->assertStringNotContainsString('recaptcha', $body);
}
public function testPreviewThenSubmitAddsTheStoryToTheQueue(): void {
$token = $this->csrfToken($this->get('/publish')['body']);
$preview = $this->post('/publish', $this->story(array('action' => 'preview', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')));
$this->assertStringContainsString('Please review your submission.', $preview['body']);
// The preview shows the purified story; the typography comes when it is saved
$this->assertStringContainsString('<p>Once -- upon a "time".</p>', $preview['body']);
$this->assertStringContainsString('<button type="submit" id="submit" name="action" value="submit">', $preview['body']);
// The solved captcha lasts until the story is saved
$this->assertStringNotContainsString('g-recaptcha', $preview['body']);
$this->assertSame(array(), $this->queries('/^INSERT/'));
$this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => $token)));
$tables = $this->tables();
$this->assertCount(1, $tables['xxx']);
$this->assertCount(1, $tables['queue']);
$row = $tables['queue'][0];
$this->assertSame(array('My story', 'Vi', 'vi@example.org', '<p>Once — upon a “time”.</p>', 'original author', 0),
array($row['title'], $row['author'], $row['email'], $row['story'], $row['editor'], $row['hidden']));
$session = $this->session();
$this->assertArrayNotHasKey('captcha_ok', $session);
$this->assertSame($row['id'], (string)$session['allow_queue_image']);
$mails = $this->mails();
$this->assertCount(1, $mails);
$this->assertSame('New submission: My story', $mails[0]['subject']);
$this->assertStringContainsString('Once — upon a “time”.', $mails[0]['body']);
}
public function testVisitorsCannotPostOrOverwrite(): void {
$token = $this->csrfToken($this->get('/publish')['body']);
$this->post('/publish', $this->story(array('action' => 'post', 'id' => '1', 'hidden' => 'on', 'editor' => 'ed',
'comments' => 'x', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')));
$tables = $this->tables();
$this->assertSame('Live', $tables['xxx'][0]['title']);
$this->assertCount(1, $tables['xxx']);
$this->assertCount(1, $tables['queue']);
$this->assertSame(array('original author', 0, ''), array($tables['queue'][0]['editor'], $tables['queue'][0]['hidden'], $tables['queue'][0]['comments']));
}
public function testAPostWithoutTheTokenOnlyGetsAPreview(): void {
$this->get('/publish');
$page = $this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => 'wrong', 'g-recaptcha-response' => 'pass')));
$this->assertStringContainsString('Your session has expired. Please check your story, then send it again.', $page['body']);
$this->assertSame(array(), $this->queries('/^INSERT/'));
$this->assertSame(array(), $this->mails());
}
public function testAFailedCaptchaStopsTheStory(): void {
$token = $this->csrfToken($this->get('/publish')['body']);
$page = $this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => 'fail')));
$this->assertStringContainsString('Please complete the captcha.', $page['body']);
$this->assertStringContainsString('g-recaptcha', $page['body']);
$this->assertSame(array(), $this->queries('/^INSERT/'));
}
public function testMissingFieldsAreShown(): void {
$token = $this->csrfToken($this->get('/publish')['body']);
$page = $this->post('/publish', array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass', 'story' => '<p>x</p>'));
$this->assertStringContainsString('<li>You have not submitted an author.</li>', $page['body']);
$this->assertStringContainsString('<label class="red" for="title">', $page['body']);
$this->assertSame(array(), $this->queries('/^INSERT/'));
}
public function testFieldsAreEscapedInTheForm(): void {
$token = $this->csrfToken($this->get('/publish')['body']);
$page = $this->post('/publish', $this->story(array('action' => 'preview', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass',
'title' => '"><script>alert(1)</script>')));
$this->assertStringNotContainsString('<script>alert(1)</script>', $page['body']);
$this->assertStringContainsString('value="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"', $page['body']);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace Xxx\Tests\Support;
/*
* A stand-in for mysqli. open_db() makes one when the global $db_class
* names this class. All connections share the static state below, so a
* test sees the queries of every connection that the code opened.
*
* Handlers answer queries: FakeDb::on('/pattern/', $answer), where the
* answer is a list of rows, TRUE, FALSE (a failed query) or a function
* that takes ($sql, $params) and returns one of these. The handler added
* last wins. A query that no handler matches gets no rows (SELECT) or
* TRUE (anything else).
*/
class FakeDb {
public static array $log = array();
public static array $handlers = array();
public static array $connections = array();
public static ?string $connectError = NULL;
// When set, every query is also appended to this file as a JSON line
public static ?string $logFile = NULL;
public $connect_error = NULL;
public $connect_errno = 0;
public $error = '';
public bool $closed = FALSE;
public static function reset(): void {
self::$log = array();
self::$handlers = array();
self::$connections = array();
self::$connectError = NULL;
self::$logFile = NULL;
}
public static function on(string $pattern, $answer): void {
self::$handlers[] = array($pattern, $answer);
}
// The SQL of every query so far
public static function queries(): array {
return array_column(self::$log, 'sql');
}
// The queries whose SQL matches $pattern, each as array('sql' => ..., 'params' => ...)
public static function matching(string $pattern): array {
return array_values(array_filter(self::$log, function ($entry) use ($pattern) {
return preg_match($pattern, $entry['sql']);
}));
}
public function __construct(...$args) {
self::$connections[] = $args;
if (self::$connectError !== NULL) {
$this->connect_errno = 1045;
$this->connect_error = self::$connectError;
}
}
public function real_escape_string($string) {
return addslashes((string)$string);
}
public function execute_query($sql, $params = array()) {
if (substr_count($sql, '?') !== count($params))
throw new \LogicException("Placeholder count does not match the parameters: $sql");
return $this->run($sql, array_values($params));
}
public function query($sql) {
return $this->run($sql, array());
}
public function close() {
$this->closed = TRUE;
return TRUE;
}
private function run(string $sql, array $params) {
$entry = array('sql' => $sql, 'params' => $params);
self::$log[] = $entry;
if (self::$logFile !== NULL)
file_put_contents(self::$logFile, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
$answer = NULL;
foreach (array_reverse(self::$handlers) as list($pattern, $handler)) {
if (preg_match($pattern, $sql)) {
$answer = is_callable($handler) ? $handler($sql, $params) : $handler;
break;
}
}
if ($answer === NULL)
$answer = preg_match('/^\s*SELECT/i', $sql) ? array() : TRUE;
if ($answer === FALSE) {
$this->error = 'Fake failure for: ' . $sql;
return FALSE;
}
return is_array($answer) ? new FakeResult($answer) : $answer;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Xxx\Tests\Support;
/*
* A stream wrapper that takes the place of https:// while it is
* installed, so that captcha_verify_response() talks to this class
* instead of Google. It records each request (the URL, the HTTP context
* options and the decoded POST body) and returns $reply: a string, FALSE
* for "server not reachable", or a Closure that gets the POST fields and
* returns one of these.
*/
class FakeRecaptcha {
public static array $requests = array();
public static $reply = '{"success": true}';
private static bool $active = FALSE;
public $context;
private string $data = '';
private int $position = 0;
public static function install($reply = '{"success": true}'): void {
self::$requests = array();
self::$reply = $reply;
if (!self::$active) {
stream_wrapper_unregister('https');
stream_wrapper_register('https', self::class);
self::$active = TRUE;
}
}
public static function uninstall(): void {
if (self::$active) {
stream_wrapper_restore('https');
self::$active = FALSE;
}
}
public function stream_open($path, $mode, $options, &$opened_path) {
$context = $this->context ? stream_context_get_options($this->context) : array();
$http = $context['http'] ?? array();
parse_str((string)($http['content'] ?? ''), $post);
self::$requests[] = array('url' => $path, 'http' => $http, 'post' => $post);
$reply = self::$reply instanceof \Closure ? (self::$reply)($post) : self::$reply;
if ($reply === FALSE)
return FALSE;
$this->data = (string)$reply;
return TRUE;
}
public function stream_read($count) {
$chunk = substr($this->data, $this->position, $count);
$this->position += strlen($chunk);
return $chunk;
}
public function stream_eof() {
return $this->position >= strlen($this->data);
}
public function stream_stat() {
return array('size' => strlen($this->data));
}
public function stream_set_option($option, $arg1, $arg2) {
return FALSE;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Xxx\Tests\Support;
// The result of a FakeDb query
class FakeResult {
private array $rows;
private int $next = 0;
public int $num_rows;
public function __construct(array $rows) {
$this->rows = array_values($rows);
$this->num_rows = count($this->rows);
}
public function fetch_assoc() {
return $this->rows[$this->next++] ?? NULL;
}
public function fetch_row() {
$row = $this->fetch_assoc();
return $row === NULL ? NULL : array_values($row);
}
public function close() {
return TRUE;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Xxx\Tests\Support;
/*
* Mail sent by code in a subprocess. PHP reads sendmail_path only at
* start-up, so code that sends mail runs in a new PHP process with
* tests/php/fixtures/fake-sendmail.php as its sendmail.
*/
class Mail {
/*
* PhpProcess::run() with the fake sendmail. The result has an extra
* 'mails' entry: a list of array('headers' => name => value,
* 'subject' => ..., 'body' => ...), decoded.
*/
public static function run(string $code, array $env = array(), array $ini = array()): array {
$log = tempnam(sys_get_temp_dir(), 'xxx-mail-');
$sendmail = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(XXX_ROOT . '/tests/php/fixtures/fake-sendmail.php');
$result = PhpProcess::run($code, array('MAIL_LOG' => $log) + $env, array('sendmail_path' => $sendmail) + $ini);
$result['mails'] = array();
foreach (file($log, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line)
$result['mails'][] = self::parse(json_decode($line, TRUE)['message']);
unlink($log);
return $result;
}
public static function parse(string $message): array {
list($head, $body) = preg_split('/\r?\n\r?\n/', $message, 2) + array('', '');
$headers = array();
foreach (preg_split('/\r?\n(?![ \t])/', $head) as $line) {
list($name, $value) = explode(':', $line, 2) + array('', '');
$headers[strtolower(trim($name))] = trim(preg_replace('/\r?\n[ \t]+/', ' ', $value));
}
$encoding = strtolower($headers['content-transfer-encoding'] ?? '');
if ($encoding === 'base64')
$body = base64_decode($body);
else if ($encoding === 'quoted-printable')
$body = quoted_printable_decode($body);
return array('headers' => $headers, 'subject' => mb_decode_mimeheader($headers['subject'] ?? ''),
'body' => str_replace("\r\n", "\n", $body));
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace Xxx\Tests\Support;
/*
* The base of the page tests. They run the site on PHP's built-in web
* server with tests/php/fixtures/router.php, and send real HTTP requests.
* Each test class gets one server, and each test gets empty state: the
* fake tables, the sessions, the temporary directory (with the login
* throttle files), the sent mail and the PHP error log.
*
* A test fails when a page logs a PHP warning, notice or error.
*/
abstract class PageTestCase extends \PHPUnit\Framework\TestCase {
protected static string $state;
protected static string $base;
private static $server = NULL;
// The cookies that the site set, name => value
protected array $cookies = array();
public static function setUpBeforeClass(): void {
self::$state = sys_get_temp_dir() . '/xxx-pages-' . bin2hex(random_bytes(6));
foreach (array('', '/sessions', '/tmp') as $dir)
mkdir(self::$state . $dir, 0700);
// A free port
$socket = stream_socket_server('tcp://127.0.0.1:0');
$address = stream_socket_get_name($socket, FALSE);
fclose($socket);
self::$base = "http://$address";
$sendmail = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(XXX_ROOT . '/tests/php/fixtures/fake-sendmail.php');
$command = array(PHP_BINARY, '-S', $address, '-t', XXX_ROOT,
'-d', 'error_reporting=-1', '-d', 'display_errors=stderr', '-d', 'log_errors=1',
'-d', 'session.save_path=' . self::$state . '/sessions',
'-d', 'session.serialize_handler=php_serialize',
'-d', 'sendmail_path=' . $sendmail,
XXX_ROOT . '/tests/php/fixtures/router.php');
$env = array(
'PATH' => getenv('PATH'),
'TMPDIR' => self::$state . '/tmp',
'XXX_PAGE_STATE' => self::$state,
'XXX_TEST_LOG' => self::$state . '/php.log',
'MAIL_LOG' => self::$state . '/mail.jsonl',
'UPLOAD_DIR' => self::$state . '/tmp/uploads',
);
$log = array('file', self::$state . '/server.log', 'a');
self::$server = proc_open($command, array(0 => array('file', '/dev/null', 'r'), 1 => $log, 2 => $log), $pipes, XXX_ROOT, $env);
for ($i = 0; $i < 100; $i++) {
$connection = @stream_socket_client("tcp://$address", $errno, $error, 0.1);
if ($connection) {
fclose($connection);
return;
}
usleep(50000);
}
self::fail('The PHP web server did not start: ' . @file_get_contents(self::$state . '/server.log'));
}
public static function tearDownAfterClass(): void {
if (self::$server) {
proc_terminate(self::$server);
proc_close(self::$server);
self::$server = NULL;
}
TestCase::removeTree(self::$state);
}
protected function setUp(): void {
foreach (array('sessions', 'tmp') as $dir) {
TestCase::removeTree(self::$state . "/$dir");
mkdir(self::$state . "/$dir", 0700);
}
foreach (array('queries.jsonl', 'mail.jsonl', 'php.log') as $file)
@unlink(self::$state . "/$file");
$this->cookies = array();
$this->setTables(array());
}
protected function tearDown(): void {
$log = (string)@file_get_contents(self::$state . '/php.log');
$this->assertDoesNotMatchRegularExpression('/PHP (Warning|Notice|Deprecated|Fatal error|Parse error)/', $log, $log);
}
// The fake tables (see SiteData) for the next requests
protected function setTables(array $tables, ?int $passwordLength = NULL): void {
$tables += array('xxx' => array(), 'queue' => array(), 'editors' => array(), 'persistent_sessions' => array());
foreach (array('xxx', 'queue') as $table)
$tables[$table] = array_map(array(SiteData::class, 'story'), $tables[$table]);
file_put_contents(self::$state . '/tables.ser', serialize(array('tables' => $tables, 'passwordLength' => $passwordLength)));
}
// The fake tables after the requests
protected function tables(): array {
return unserialize(file_get_contents(self::$state . '/tables.ser'))['tables'];
}
// The queries that the pages sent, each as array('sql' => ..., 'params' => ...)
protected function queries(string $pattern = '/./'): array {
$file = self::$state . '/queries.jsonl';
$queries = is_file($file) ? array_map(function ($line) { return json_decode($line, TRUE); }, file($file, FILE_IGNORE_NEW_LINES)) : array();
return array_values(array_filter($queries, function ($query) use ($pattern) { return preg_match($pattern, $query['sql']); }));
}
// The mail that the pages sent, decoded (see Mail::parse)
protected function mails(): array {
$file = self::$state . '/mail.jsonl';
if (!is_file($file))
return array();
return array_map(function ($line) { return Mail::parse(json_decode($line, TRUE)['message']); }, file($file, FILE_IGNORE_NEW_LINES));
}
// Start a session with $data, as if the browser had its cookie; returns the id
protected function startSession(array $data): string {
$id = bin2hex(random_bytes(16));
file_put_contents(self::$state . "/sessions/sess_$id", serialize($data));
$this->cookies['PHPSESSID'] = $id;
return $id;
}
// An editor's session, with a CSRF token; returns the token
protected function loginAs(string $username = 'ed', array $data = array()): string {
$token = str_repeat('c0ffee', 10) . 'c0de';
$this->startSession($data + array('editor' => $username, 'csrf_token' => $token));
return $token;
}
// The data of the session in the browser's cookie, or NULL
protected function session(): ?array {
$file = self::$state . '/sessions/sess_' . ($this->cookies['PHPSESSID'] ?? '');
if (!isset($this->cookies['PHPSESSID']) || !is_file($file))
return NULL;
$data = file_get_contents($file);
return $data === '' ? array() : unserialize($data);
}
// The CSRF token of the current session, from a page
protected function csrfToken(string $page): string {
$this->assertMatchesRegularExpression('/name="csrf_token" value="([0-9a-f]{64})"/', $page);
preg_match('/name="csrf_token" value="([0-9a-f]{64})"/', $page, $match);
return $match[1];
}
/*
* Send a request. Returns array('status' => code, 'headers' => lower-case
* name => list of values, 'body' => ...). Cookies go and come back as a
* browser sends them, without regard to domain or the secure flag.
*/
protected function request(string $method, string $path, array $post = array(), array $headers = array()): array {
if ($this->cookies) {
$headers['Cookie'] = implode('; ', array_map(function ($name, $value) { return "$name=" . rawurlencode($value); },
array_keys($this->cookies), $this->cookies));
}
$options = array('method' => $method, 'follow_location' => 0, 'ignore_errors' => TRUE, 'timeout' => 30);
if ($method === 'POST') {
$options['content'] = http_build_query($post);
$headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
$options['header'] = implode("\r\n", array_map(function ($name, $value) { return "$name: $value"; },
array_keys($headers), $headers));
$body = file_get_contents(self::$base . $path, FALSE, stream_context_create(array('http' => $options)));
$this->assertNotFalse($body, "No answer for $method $path");
$lines = http_get_last_response_headers();
preg_match('#^HTTP/\S+ (\d+)#', array_shift($lines), $match);
$response = array('status' => (int)$match[1], 'headers' => array(), 'body' => $body);
foreach ($lines as $line) {
list($name, $value) = explode(':', $line, 2);
$response['headers'][strtolower($name)][] = trim($value);
}
foreach ($response['headers']['set-cookie'] ?? array() as $cookie) {
$parts = explode(';', $cookie);
list($name, $value) = explode('=', array_shift($parts), 2);
$expired = FALSE;
foreach ($parts as $part) {
if (preg_match('/^\s*expires=(.*)$/i', $part, $match) && strtotime($match[1]) < time())
$expired = TRUE;
}
if ($expired || $value === '' || $value === 'deleted')
unset($this->cookies[$name]);
else
$this->cookies[$name] = rawurldecode($value);
}
return $response;
}
protected function get(string $path, array $headers = array()): array {
return $this->request('GET', $path, array(), $headers);
}
protected function post(string $path, array $post, array $headers = array()): array {
return $this->request('POST', $path, $post, $headers);
}
protected function header(array $response, string $name): ?string {
return $response['headers'][strtolower($name)][0] ?? NULL;
}
protected function assertRedirect(string $location, array $response): void {
$this->assertSame(302, $response['status'], $response['body']);
$this->assertSame($location, $this->header($response, 'Location'));
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Xxx\Tests\Support;
/*
* Run PHP code in a new process, after tests/php/bootstrap.php. This is
* for code that ends the script (die, exit) or that needs php.ini
* settings which cannot change at run time, such as sendmail_path.
*/
class PhpProcess {
/*
* Returns array('status' => exit code, 'stdout' => ..., 'stderr' => ...,
* 'log' => what the code wrote to the PHP error log).
*/
public static function run(string $code, array $env = array(), array $ini = array()): array {
// One line, so that line numbers in $code stay the same; the code runs
// in publish/, as the unit tests do
return self::runPlain("require " . var_export(XXX_ROOT . '/tests/php/bootstrap.php', TRUE) . "; chdir(XXX_ROOT . '/publish');\n" . $code, $env, $ini);
}
// The same with only the test environment, for code that includes a site library itself
public static function runBare(string $code, array $env = array(), array $ini = array()): array {
return self::runPlain("require " . var_export(XXX_ROOT . '/tests/php/environment.php', TRUE) . ";\n" . $code, $env, $ini);
}
// The same without the bootstrap: the code loads what it needs itself
public static function runPlain(string $code, array $env = array(), array $ini = array()): array {
$script = tempnam(sys_get_temp_dir(), 'xxx-php-');
$log = $script . '.log';
file_put_contents($script, "<?php\n" . $code);
$command = array(PHP_BINARY, '-d', 'display_errors=stderr', '-d', 'error_reporting=-1', '-d', "error_log=$log");
foreach ($ini as $name => $value)
array_push($command, '-d', "$name=$value");
$command[] = $script;
$env = array_merge(getenv(), array('XXX_TEST_LOG' => $log), $env);
$process = proc_open($command, array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes, XXX_ROOT, $env);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$status = proc_close($process);
$logged = is_file($log) ? file_get_contents($log) : '';
@unlink($script);
@unlink($log);
return array('status' => $status, 'stdout' => $stdout, 'stderr' => $stderr, 'log' => $logged);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace Xxx\Tests\Support;
/*
* In-memory copies of the site tables (xxx, queue, editors and
* persistent_sessions), with FakeDb handlers for the queries that the
* code sends. It knows only those queries, not SQL in general.
*
* Story rows have the columns id, title, author, email, date, story,
* hidden, img, mime, comments and editor.
*/
class SiteData {
public static array $tables = array();
public static int $lastInsertId = 0;
// The size of editors.password; NULL means that the column keeps any hash
public static ?int $passwordLength = NULL;
public static function install(array $tables = array()): void {
self::$tables = $tables + array('xxx' => array(), 'queue' => array(), 'editors' => array(), 'persistent_sessions' => array());
foreach (array('xxx', 'queue') as $table)
self::$tables[$table] = array_map(array(self::class, 'story'), self::$tables[$table]);
self::$lastInsertId = 0;
$stories = '(xxx|queue)';
FakeDb::on("/^SELECT \\* FROM $stories\\b/", array(self::class, 'selectStories'));
FakeDb::on("/^SELECT COUNT\\(\\*\\) FROM $stories\\b/", function ($sql, $params) {
return array(array('COUNT(*)' => (string)count(self::filterStories($sql, $params))));
});
FakeDb::on('/^SELECT MAX\(`date`\) FROM xxx/', function () {
$dates = array_column(self::$tables['xxx'], 'date');
return array(array('MAX(`date`)' => $dates ? max($dates) : NULL));
});
FakeDb::on("/^SELECT img, mime FROM $stories\\b/i", function ($sql, $params) {
$rows = self::filterStories($sql, $params);
return array_map(function ($row) { return array('img' => $row['img'], 'mime' => $row['mime']); }, array_slice($rows, 0, 1));
});
FakeDb::on("/^INSERT INTO $stories SET /", array(self::class, 'insertStory'));
FakeDb::on("/^UPDATE $stories SET /", array(self::class, 'updateStory'));
FakeDb::on("/^DELETE FROM $stories WHERE `id` = \\? LIMIT 1/", function ($sql, $params) {
$table = self::tableOf($sql);
self::$tables[$table] = array_values(array_filter(self::$tables[$table], function ($row) use ($params) {
return (int)$row['id'] !== (int)$params[0];
}));
return TRUE;
});
FakeDb::on('/^SELECT LAST_INSERT_ID\(\)/', function () {
return array(array('LAST_INSERT_ID()' => (string)self::$lastInsertId));
});
FakeDb::on('/^SELECT (\*|username|password) FROM editors WHERE username = \?/', function ($sql, $params) {
return array_slice(array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
return $row['username'] === $params[0];
})), 0, 1);
});
FakeDb::on('/^UPDATE editors SET password = \? WHERE username = \?/', function ($sql, $params) {
foreach (self::$tables['editors'] as &$row) {
if ($row['username'] === $params[1])
$row['password'] = self::cut($params[0]);
}
return TRUE;
});
FakeDb::on('/^INSERT INTO editors SET `username` = \?, `password` = \?, `email` = \?/', function ($sql, $params) {
self::$tables['editors'][] = array('username' => $params[0], 'password' => self::cut($params[1]), 'email' => $params[2]);
return TRUE;
});
FakeDb::on('/^DELETE FROM editors WHERE username = \?/', function ($sql, $params) {
self::$tables['editors'] = array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
return $row['username'] !== $params[0];
}));
return TRUE;
});
FakeDb::on('/^SELECT \* FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
return array_slice(array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
return $row['username'] === $params[0] && $row['session'] === $params[1];
})), 0, 1);
});
FakeDb::on('/^INSERT INTO persistent_sessions \(username, session\) VALUES \(\?, \?\)/', function ($sql, $params) {
self::$tables['persistent_sessions'][] = array('username' => $params[0], 'session' => $params[1], 'created' => gmdate('Y-m-d H:i:s'));
return TRUE;
});
FakeDb::on('/^DELETE FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
return !($row['username'] === $params[0] && $row['session'] === $params[1]);
}));
return TRUE;
});
FakeDb::on('/^DELETE FROM persistent_sessions\s+WHERE created < DATE_SUB/', function () {
$limit = gmdate('Y-m-d H:i:s', strtotime('-1 year'));
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($limit) {
return strlen($row['session']) === 64 && ($row['created'] ?? $limit) >= $limit;
}));
return TRUE;
});
}
// A story row with every column, from the columns given
public static function story(array $row): array {
static $next = 1;
$row += array('id' => $next, 'title' => 'Title', 'author' => 'Author', 'email' => '',
'date' => '2020-01-01 10:00:00', 'story' => '<p>Story.</p>', 'hidden' => '0',
'img' => NULL, 'mime' => NULL, 'comments' => '', 'editor' => 'original author');
$next = max($next, (int)$row['id']) + 1;
$row['id'] = (string)$row['id'];
return $row;
}
public static function tableOf(string $sql): string {
preg_match('/\b(FROM|INTO|UPDATE)\s+`?(xxx|queue)\b/', $sql, $match);
return $match[2];
}
// The rows of the story table in $sql that its WHERE clause selects
public static function filterStories(string $sql, array $params): array {
$rows = self::$tables[self::tableOf($sql)];
if (stripos($sql, 'hidden IS NOT TRUE') !== FALSE)
$rows = array_filter($rows, function ($row) { return !$row['hidden']; });
if (preg_match('/\bid = (\d+)/i', $sql, $match))
$rows = array_filter($rows, function ($row) use ($match) { return (int)$row['id'] === (int)$match[1]; });
else if (preg_match('/\bid = \?/i', $sql))
$rows = array_filter($rows, function ($row) use ($params) { return (int)$row['id'] === (int)$params[0]; });
if (preg_match('/title LIKE \?/', $sql)) {
$term = strtolower(trim($params[0], '%'));
$rows = array_filter($rows, function ($row) use ($term) {
foreach (array('title', 'author', 'date', 'story') as $column) {
if (str_contains(strtolower((string)$row[$column]), $term))
return TRUE;
}
return FALSE;
});
}
return array_values($rows);
}
public static function selectStories(string $sql, array $params): array {
$rows = self::filterStories($sql, $params);
if (preg_match('/ORDER BY (\w+) (asc|desc)/i', $sql, $match)) {
$column = $match[1];
usort($rows, function ($a, $b) use ($column) { return strcmp((string)$a[$column], (string)$b[$column]); });
if (strtolower($match[2]) === 'desc')
$rows = array_reverse($rows);
}
if (preg_match('/LIMIT (\d+)\s*,\s*(\d+)/', $sql, $match))
$rows = array_slice($rows, (int)$match[1], (int)$match[2]);
return $rows;
}
// The column => value pairs of "SET `a` = ?, `b` = ?"
private static function assignments(string $sql, array $params): array {
preg_match_all('/`(\w+)` = \?/', explode(' WHERE ', $sql)[0], $matches);
return array_combine($matches[1], array_slice($params, 0, count($matches[1])));
}
public static function insertStory(string $sql, array $params) {
$ids = array_merge(array_column(self::$tables['xxx'], 'id'), array_column(self::$tables['queue'], 'id'), array(0));
$row = self::story(array('id' => max(array_map('intval', $ids)) + 1) + self::assignments($sql, $params));
self::$tables[self::tableOf($sql)][] = $row;
self::$lastInsertId = (int)$row['id'];
return TRUE;
}
public static function updateStory(string $sql, array $params) {
$id = (int)end($params);
$table = self::tableOf($sql);
foreach (self::$tables[$table] as &$row) {
if ((int)$row['id'] === $id)
$row = array_merge($row, self::assignments($sql, $params));
}
return TRUE;
}
private static function cut(string $value): string {
return self::$passwordLength === NULL ? $value : substr($value, 0, self::$passwordLength);
}
// The rows of a story table, found by id
public static function find(string $table, $id): ?array {
foreach (self::$tables[$table] as $row) {
if ((int)$row['id'] === (int)$id)
return $row;
}
return NULL;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Xxx\Tests\Support;
/*
* The base of the unit tests. Before each test it moves to publish/ (as
* the publishing pages run there), empties the request,
* the session and the globals that the site code uses, gives the test a
* fake database with empty tables and a temporary directory (also the
* upload directory), and clears the error log.
*/
abstract class TestCase extends \PHPUnit\Framework\TestCase {
protected string $tmp;
private array $envNames = array();
private string $cwd;
protected function setUp(): void {
// publish/post.lib.php includes files by paths relative to publish/
$this->cwd = getcwd();
chdir(XXX_ROOT . '/publish');
FakeDb::reset();
SiteData::$passwordLength = NULL;
SiteData::install();
$GLOBALS['db'] = NULL;
$GLOBALS['db_class'] = FakeDb::class;
$_GET = $_POST = $_REQUEST = $_FILES = $_COOKIE = $_SESSION = array();
$_SERVER['REMOTE_ADDR'] = '192.0.2.1';
$_SERVER['REQUEST_URI'] = '/';
unset($_SERVER['HTTPS']);
foreach (array('is_admin' => FALSE, 'action' => NULL, 'queue' => NULL, 'id' => NULL,
'num_stories' => NULL, 'editable' => NULL, 'username' => NULL,
'validate' => array()) as $name => $value)
$GLOBALS[$name] = $value;
unset($GLOBALS['file_src']);
$this->tmp = sys_get_temp_dir() . '/xxx-test-' . bin2hex(random_bytes(6));
mkdir($this->tmp, 0700);
$this->setEnv('UPLOAD_DIR', $this->tmp . '/uploads');
file_put_contents(XXX_TEST_LOG, '');
}
protected function tearDown(): void {
FakeRecaptcha::uninstall();
foreach ($this->envNames as $name)
putenv($name);
$this->envNames = array();
self::removeTree($this->tmp);
chdir($this->cwd);
}
// Set an environment variable (which config() reads first) for this test
protected function setEnv(string $name, string $value): void {
$this->envNames[] = $name;
putenv("$name=$value");
}
protected function request(array $get = array(), array $post = array()): void {
$_GET = $get;
$_POST = $post;
$_REQUEST = array_merge($get, $post);
}
protected function asEditor(string $name = 'ed'): void {
$GLOBALS['is_admin'] = TRUE;
$GLOBALS['username'] = $name;
$_SESSION['editor'] = $name;
}
// What the code wrote to the PHP error log during this test
protected function loggedErrors(): string {
return (string)file_get_contents(XXX_TEST_LOG);
}
// A 1x1 PNG image
public static function png(): string {
return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
}
public static function removeTree(string $path): void {
if (is_dir($path) && !is_link($path)) {
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..')
self::removeTree("$path/$name");
}
rmdir($path);
} else if (file_exists($path) || is_link($path)) {
unlink($path);
}
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeRecaptcha;
use Xxx\Tests\Support\TestCase;
// captcha.lib.php. FakeRecaptcha answers in place of Google.
class CaptchaTest extends TestCase {
private function disable(): void {
$this->setEnv('RECAPTCHA_SITE_KEY', '');
}
public function testEnabledWithASiteKey(): void {
$this->assertTrue(captcha_enabled());
$this->disable();
$this->assertFalse(captcha_enabled());
}
public function testScriptAndWidget(): void {
$this->assertSame('<script src="https://www.google.com/recaptcha/api.js" async defer></script>', captcha_script());
$this->assertSame('<div class="g-recaptcha" data-sitekey="test-site-key"></div>', captcha_widget());
}
public function testWidgetEscapesTheSiteKey(): void {
$this->setEnv('RECAPTCHA_SITE_KEY', '"><script>x</script>');
$this->assertSame('<div class="g-recaptcha" data-sitekey="&quot;&gt;&lt;script&gt;x&lt;/script&gt;"></div>', captcha_widget());
}
public function testNothingFromGoogleWithoutASiteKey(): void {
$this->disable();
$this->assertSame('', captcha_script());
$this->assertSame('', captcha_widget());
}
public function testEveryCheckPassesWithoutASiteKey(): void {
$this->disable();
FakeRecaptcha::install(FALSE);
$this->assertTrue(captcha_verify_response());
$this->assertTrue(captcha_check());
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testNoResponseFailsWithoutAskingGoogle(): void {
FakeRecaptcha::install();
$this->assertFalse(captcha_verify_response());
$this->request(array(), array('g-recaptcha-response' => ''));
$this->assertFalse(captcha_verify_response());
$this->request(array(), array('g-recaptcha-response' => array('x')));
$this->assertFalse(captcha_verify_response());
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testSolvedCaptchaPasses(): void {
FakeRecaptcha::install('{"success": true, "hostname": "xxx.org.za"}');
$_SERVER['REMOTE_ADDR'] = '198.51.100.7';
$this->request(array(), array('g-recaptcha-response' => 'token-from-widget'));
$this->assertTrue(captcha_verify_response());
}
// The secret goes in the POST body, never in the URL
public function testRequestSendsTheSecretByPost(): void {
FakeRecaptcha::install();
$_SERVER['REMOTE_ADDR'] = '198.51.100.7';
$this->request(array(), array('g-recaptcha-response' => 'token-from-widget'));
captcha_verify_response();
$this->assertCount(1, FakeRecaptcha::$requests);
$request = FakeRecaptcha::$requests[0];
$this->assertSame('https://www.google.com/recaptcha/api/siteverify', $request['url']);
$this->assertSame('POST', $request['http']['method']);
$this->assertSame(array('secret' => 'test-secret', 'response' => 'token-from-widget', 'remoteip' => '198.51.100.7'), $request['post']);
$this->assertStringContainsString('application/x-www-form-urlencoded', $request['http']['header']);
$this->assertSame(10, $request['http']['timeout']);
}
public function testUnsolvedCaptchaFails(): void {
FakeRecaptcha::install('{"success": false, "error-codes": ["invalid-input-response"]}');
$this->request(array(), array('g-recaptcha-response' => 'bad'));
$this->assertFalse(captcha_verify_response());
}
public function testOddRepliesFail(): void {
$this->request(array(), array('g-recaptcha-response' => 'token'));
foreach (array('', 'not json', '[]', '"success"', '{"success": 0}', '{}') as $reply) {
FakeRecaptcha::install($reply);
$this->assertFalse(captcha_verify_response(), $reply);
}
}
public function testUnreachableServerFailsAndIsLogged(): void {
FakeRecaptcha::install(FALSE);
$this->request(array(), array('g-recaptcha-response' => 'token'));
$this->assertFalse(captcha_verify_response());
$this->assertStringContainsString('xxx: could not reach the reCAPTCHA server', $this->loggedErrors());
}
public function testMissingSecretFailsAndIsLogged(): void {
$this->setEnv('RECAPTCHA_SECRET', '');
FakeRecaptcha::install();
$this->request(array(), array('g-recaptcha-response' => 'token'));
$this->assertFalse(captcha_verify_response());
$this->assertSame(array(), FakeRecaptcha::$requests);
$this->assertStringContainsString('RECAPTCHA_SECRET is not set', $this->loggedErrors());
}
public function testPassLastsHalfAnHour(): void {
$this->assertFalse(captcha_passed());
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS + 5;
$this->assertTrue(captcha_passed());
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS - 1;
$this->assertFalse(captcha_passed());
}
public function testCheckRemembersASolvedCaptcha(): void {
FakeRecaptcha::install();
$this->request(array(), array('g-recaptcha-response' => 'token'));
$this->assertTrue(captcha_check());
$this->assertEqualsWithDelta(time(), $_SESSION['captcha_ok'], 2);
// The next check needs no new answer from Google
FakeRecaptcha::install(FALSE);
$this->request();
$this->assertTrue(captcha_check());
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testCheckFailsWithoutAPassOrAnAnswer(): void {
FakeRecaptcha::install();
$this->assertFalse(captcha_check());
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
}
public function testUsedEndsThePass(): void {
$_SESSION['captcha_ok'] = time();
captcha_used();
$this->assertFalse(captcha_passed());
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\TestCase;
/*
* config() in config.lib.php. It reads the .env file once per request,
* and config.lib.php calls it as soon as it loads, so each test runs
* config.lib.php in a new PHP process.
*/
class ConfigTest extends TestCase {
/*
* Load config.lib.php with $content as the .env file and return
* config($name, $default) for each array($name, $default) in $calls.
*/
private function config(string $content, array $calls, array $env = array(), string $between = ''): array {
file_put_contents($this->tmp . '/env', $content);
$code = 'require ' . var_export(XXX_ROOT . '/config.lib.php', TRUE) . ";\n\$out = array();\n";
foreach ($calls as $i => $call) {
if ($i === 1)
$code .= $between;
$code .= '$out[] = config(' . var_export($call[0], TRUE) . ', ' . var_export($call[1] ?? NULL, TRUE) . ");\n";
}
$code .= 'echo json_encode($out);';
$result = PhpProcess::runPlain($code, $env + array('XXX_ENV_FILE' => $this->tmp . '/env'));
$this->assertSame('', $result['stderr']);
return json_decode($result['stdout'], TRUE);
}
public function testReadsNameValueLines(): void {
$this->assertSame(array('example.org', 'stories'),
$this->config("DB_HOST=example.org\nDB_NAME=stories\n", array(array('DB_HOST'), array('DB_NAME'))));
}
public function testSkipsCommentsBlankLinesAndLinesWithoutEquals(): void {
$this->assertSame(array(NULL, NULL, 'real'),
$this->config("# DB_HOST=commented\n\n \nnot a setting\nDB_USER=real\n",
array(array('DB_HOST'), array('not a setting'), array('DB_USER'))));
}
public function testRemovesMatchingQuotesAndSpaces(): void {
$this->assertSame(array('double quoted', 'single quoted', "\"unbalanced'", '', '"x'),
$this->config("A = \"double quoted\" \nB='single quoted'\nC=\"unbalanced'\nD=\"\"\nE=\"x\n",
array(array('A'), array('B'), array('C'), array('D'), array('E'))));
}
public function testKeepsEqualsSignsInValues(): void {
$this->assertSame(array('a=b==c'), $this->config("DB_PASS=a=b==c\n", array(array('DB_PASS'))));
}
public function testAcceptsExportPrefix(): void {
$this->assertSame(array('exported'), $this->config("export DB_HOST=exported\n", array(array('DB_HOST'))));
}
public function testTheLastOfTwoLinesWins(): void {
$this->assertSame(array('second'), $this->config("KEY=first\nKEY=second\n", array(array('KEY'))));
}
public function testEnvironmentBeatsTheFile(): void {
$this->assertSame(array('from-environment'),
$this->config("DB_HOST=from-file\n", array(array('DB_HOST')), array('DB_HOST' => 'from-environment')));
}
public function testDefaultWhenMissing(): void {
$this->assertSame(array(NULL, 'fallback'),
$this->config("OTHER=1\n", array(array('MISSING'), array('MISSING', 'fallback'))));
}
public function testEmptyValueIsNotTheDefault(): void {
$this->assertSame(array(''), $this->config("EMPTY=\n", array(array('EMPTY', 'fallback'))));
}
public function testMissingFileGivesDefaults(): void {
$result = PhpProcess::runPlain('require ' . var_export(XXX_ROOT . '/config.lib.php', TRUE) . '; echo config("DB_HOST", "fallback");',
array('XXX_ENV_FILE' => $this->tmp . '/does-not-exist'));
$this->assertSame('fallback', $result['stdout']);
}
public function testFileIsReadOnlyOnce(): void {
$this->assertSame(array('before', 'before'),
$this->config("KEY=before\n", array(array('KEY'), array('KEY')), array(),
'file_put_contents(getenv("XXX_ENV_FILE"), "KEY=after\n");'));
}
public function testDisplayErrorsIsOffUnlessConfigured(): void {
$code = 'require ' . var_export(XXX_ROOT . '/config.lib.php', TRUE) . '; echo ini_get("display_errors"), "|", ini_get("log_errors");';
file_put_contents($this->tmp . '/env', "DISPLAY_ERRORS=\n");
$this->assertSame('|1', PhpProcess::runPlain($code, array('XXX_ENV_FILE' => $this->tmp . '/env'))['stdout']);
file_put_contents($this->tmp . '/env', "DISPLAY_ERRORS=stderr\n");
$this->assertSame('stderr|1', PhpProcess::runPlain($code, array('XXX_ENV_FILE' => $this->tmp . '/env'))['stdout']);
file_put_contents($this->tmp . '/env', "OTHER=1\n");
$this->assertSame('0|1', PhpProcess::runPlain($code, array('XXX_ENV_FILE' => $this->tmp . '/env'))['stdout']);
}
public function testTheTestsUseTheTestFile(): void {
$this->assertSame('test-site-key', config('RECAPTCHA_SITE_KEY'));
}
public function testExampleFileListsEverySettingTheCodeReads(): void {
$code = implode('', array_map('file_get_contents', array_merge(
glob(XXX_ROOT . '/*.php'), glob(XXX_ROOT . '/lib/*.php'), glob(XXX_ROOT . '/publish/*.php'))));
preg_match_all("/config\\('([A-Z_]+)'/", $code, $used);
$example = file_get_contents(XXX_ROOT . '/.env.example');
$this->assertNotEmpty($used[1]);
foreach (array_unique($used[1]) as $name)
$this->assertMatchesRegularExpression("/^$name=/m", $example, "$name is missing from .env.example");
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// delete_story() in publish/post.lib.php
class DeleteStoryTest extends TestCase {
protected function setUp(): void {
parent::setUp();
SiteData::install(array('xxx' => array(array('id' => 1), array('id' => 2)),
'queue' => array(array('id' => 1), array('id' => 3))));
open_db('open');
}
private function ids(string $table): array {
return array_map('intval', array_column(SiteData::$tables[$table], 'id'));
}
public function testVisitorsCannotDelete(): void {
$this->assertSame('', delete_story(1));
$this->assertSame(array(), FakeDb::matching('/^DELETE/'));
}
public function testEditorsDeleteFromTheLiveTable(): void {
$this->asEditor();
$this->assertSame('Article has been deleted.', delete_story(1));
$this->assertSame(array(2), $this->ids('xxx'));
$this->assertSame(array(1, 3), $this->ids('queue'));
}
public function testEditorsDeleteFromTheQueue(): void {
$this->asEditor();
$this->request(array('queue' => '1'));
$this->assertSame('Article has been deleted.', delete_story(3));
$this->assertSame(array(1), $this->ids('queue'));
$this->assertSame(array(1, 2), $this->ids('xxx'));
}
public function testAMoveDeletesTheQueueCopyQuietly(): void {
$this->asEditor();
$this->assertSame('', delete_story(1, TRUE));
$this->assertSame(array(3), $this->ids('queue'));
$this->assertSame(array(1, 2), $this->ids('xxx'));
}
public function testTheIdIsBoundAsAnInteger(): void {
$this->asEditor();
delete_story('2 OR 1=1');
$query = FakeDb::matching('/^DELETE/')[0];
$this->assertSame('DELETE FROM xxx WHERE `id` = ? LIMIT 1', $query['sql']);
$this->assertSame(array(2), $query['params']);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// display_stories() in get_data.lib.php
class DisplayStoriesTest extends TestCase {
protected function setUp(): void {
parent::setUp();
$GLOBALS['num_stories'] = 5;
SiteData::install(array(
'xxx' => array(
array('id' => 1, 'title' => 'First <em>title</em>', 'author' => 'Ann & "Bo"', 'date' => '2020-01-02 03:04:05',
'story' => '<p onclick="steal()">One <script>alert(1)</script>hundred</p>'),
array('id' => 2, 'title' => 'Second', 'author' => 'Cy', 'date' => '2020-01-01 10:00:00', 'story' => '<p>Two</p>'),
array('id' => 3, 'title' => 'Picture', 'author' => 'Di', 'date' => '2019-12-31 10:00:00', 'story' => '',
'img' => 'PNG', 'mime' => 'image/png', 'hidden' => '1'),
),
'queue' => array(
array('id' => 20, 'title' => 'Waiting', 'author' => 'Ed', 'date' => '2020-02-01 10:00:00'),
),
));
open_db('open');
}
public function testListsStoriesWithEscapedFields(): void {
$page = display_stories();
$this->assertStringStartsWith('<ul class="article-list">', $page);
$this->assertSame(2, substr_count($page, '<li class="box">'));
$this->assertStringContainsString('<a href="/article?id=1">First &lt;em&gt;title&lt;/em&gt;</a>', $page);
$this->assertStringContainsString('<dd class="author">Ann &amp; &quot;Bo&quot;</dd>', $page);
$this->assertStringNotContainsString('<em>title</em>', $page);
}
public function testStoryHtmlIsPurified(): void {
$page = display_stories();
$this->assertStringContainsString('<p>One hundred</p>', $page);
$this->assertStringNotContainsString('onclick', $page);
$this->assertStringNotContainsString('alert', $page);
}
public function testDatesAreShownWithoutSecondsAndMarkedUp(): void {
$page = display_stories();
$datetime = date('Y-m-d\TH:i:sP', strtotime('2020-01-02 03:04:05'));
$this->assertStringContainsString('<time datetime="' . $datetime . '">2020-01-02 03:04</time>', $page);
}
public function testHiddenStoriesAreLeftOutForVisitors(): void {
$this->assertStringNotContainsString('Picture', display_stories());
}
public function testEditorsSeeHiddenStoriesMarkedAndEditLinks(): void {
$this->asEditor();
$page = display_stories();
$this->assertStringContainsString('<span class="right red">[hidden]</span>', $page);
$this->assertStringContainsString('[<a href="/publish/edit?id=3&amp;queue=0">edit</a>]', $page);
}
public function testImagesAreServedByDisplay(): void {
$this->asEditor();
$this->assertStringContainsString('<img src="/display?id=3" alt="Picture" />', display_stories());
}
public function testEditableListLinksTitlesToTheEditor(): void {
$this->asEditor();
$GLOBALS['editable'] = TRUE;
$this->request(array('queue' => '1'));
$page = display_stories();
$this->assertStringContainsString('<a href="/publish/edit?id=20&amp;queue=1">Waiting</a>', $page);
$this->assertStringNotContainsString('[<a href', $page);
}
public function testEditableImagesKeepTheQueue(): void {
$this->asEditor();
$GLOBALS['editable'] = TRUE;
$page = display_stories('3');
$this->assertStringContainsString('<img src="/display?id=3&amp;queue=0" alt="Picture" />', $page);
}
public function testOneStoryHasNoPager(): void {
$page = display_stories('2');
$this->assertSame(1, substr_count($page, '<li class="box">'));
$this->assertStringNotContainsString('class="pager"', $page);
}
public function testSeveralStoriesHaveAPager(): void {
$this->assertStringContainsString('<div class="pager">', display_stories());
}
public function testNoStories(): void {
$page = display_stories('99');
$this->assertStringContainsString('<p>No articles found.</p>', $page);
$this->assertStringNotContainsString('Try searching again', $page);
}
public function testNoSearchResults(): void {
$this->request(array('search' => 'nothing like this'));
$page = display_stories();
$this->assertStringContainsString('<p>No articles found.</p>', $page);
$this->assertStringContainsString('Try searching again', $page);
}
public function testPreviewShowsThePostedStoryWithoutLinks(): void {
$GLOBALS['action'] = 'preview';
$this->request(array(), array('title' => '<b>Mine</b>', 'author' => 'Me', 'story' => '<p>My <i>story</i></p><script>x()</script>'));
$page = display_stories();
$this->assertStringContainsString('<h3 class="articletitle">&lt;b&gt;Mine&lt;/b&gt;</h3>', $page);
$this->assertStringContainsString('<dd class="date">Awaiting posting</dd>', $page);
$this->assertStringContainsString('<p>My <i>story</i></p>', $page);
$this->assertStringNotContainsString('x()', $page);
}
public function testPreviewShowsAPendingImage(): void {
$GLOBALS['action'] = 'preview';
$GLOBALS['file_src'] = '/display?pending="><script>';
$this->request(array(), array('title' => 'Pic', 'author' => 'Me'));
$this->assertStringContainsString('<img src="/display?pending=&quot;&gt;&lt;script&gt;" alt="Pic" />', display_stories());
}
public function testVisitorSubmitShowsTheirStory(): void {
$GLOBALS['action'] = 'submit';
$this->request(array(), array('title' => 'Sent', 'author' => 'Me', 'story' => '<p>Done</p>'));
$page = display_stories();
$this->assertMatchesRegularExpression('/<dd class="date">Posted \d{4}-\d\d-\d\d \d\d:\d\d<\/dd>/', $page);
$this->assertStringContainsString('<h3 class="articletitle">Sent</h3>', $page);
}
public function testVisitorSeesTheImageTheySubmittedFromTheQueue(): void {
$GLOBALS['action'] = 'submit';
$_SESSION['allow_queue_image'] = '20';
$this->request(array(), array('title' => 'Sent', 'author' => 'Me', 'uploaded' => 'yes'));
$this->assertStringContainsString('<img src="/display?id=20" alt="Sent" />', display_stories());
}
// An editor's submit saves to the queue, and the page shows the saved row
public function testEditorSubmitShowsTheSavedStoryFromTheQueue(): void {
$this->asEditor();
$GLOBALS['action'] = 'submit';
$page = display_stories('20');
$this->assertStringContainsString('Waiting', $page);
$this->assertStringContainsString('<p>No articles found.</p>', display_stories('2'));
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\Mail;
use Xxx\Tests\Support\TestCase;
// send_email() in publish/post.lib.php
class EmailTest extends TestCase {
private function send(string $title, string $author, string $story): array {
$result = Mail::run('send_email(' . var_export($title, TRUE) . ', ' . var_export($author, TRUE) . ', ' . var_export($story, TRUE) . ');');
$this->assertSame(0, $result['status'], $result['stderr']);
$this->assertCount(1, $result['mails'], $result['stderr']);
$this->assertSame('', $result['log']);
return $result['mails'][0];
}
public function testGoesToTheEditorsFromTheQueue(): void {
$mail = $this->send('A title', 'Ann', '<p>Hello.</p>');
$this->assertSame('editor@xxx.org.za', $mail['headers']['to']);
$this->assertSame('x.x.x Queue <editor@xxx.org.za>', $mail['headers']['from']);
$this->assertSame('New submission: A title', $mail['subject']);
}
public function testSubjectKeepsNonAsciiTitles(): void {
$this->assertSame('New submission: Ōmega café', $this->send('Ōmega café', 'Ann', '<p>x</p>')['subject']);
}
public function testBodyIsPlainTextOfTheStory(): void {
$mail = $this->send('T', 'Ann', "<p>First &amp; <strong>bold</strong>.</p>\n<p>Line one<br />line two</p><p></p><p>&lt;b&gt; is text &#8220;quoted&#8221;</p>");
$this->assertSame("Please review the queue. Ann has sent the following new submission:\n\n"
. "First & bold.\n\nLine one\nline two\n\n<b> is text “quoted”"
. "\n\nhttp://xxx.org.za/publish/edit", trim($mail['body']));
}
public function testLongLinesAreWrapped(): void {
$body = $this->send('T', 'Ann', '<p>' . str_repeat('word ', 40) . '</p>')['body'];
foreach (explode("\n", trim($body)) as $line)
$this->assertLessThanOrEqual(75, strlen($line));
}
public function testImageSubmissionSaysSo(): void {
$body = $this->send('T', 'Ann', '')['body'];
$this->assertStringContainsString("new submission:\n\n<Submission is an image. Review on the site.>", $body);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// html_entity_decode_full() and convert_entity() in lib/convert_entities.inc.php
class EntitiesTest extends TestCase {
public function testNamedEntitiesAreDecoded(): void {
$this->assertSame("Wait\u{2026} \u{2014} \u{201C}yes\u{201D}", html_entity_decode_full('Wait&hellip; &mdash; &ldquo;yes&rdquo;'));
}
public function testNumericEntitiesAreDecoded(): void {
$this->assertSame("\u{2013} A", html_entity_decode_full('&#8211; &#x41;'));
}
public function testTheXmlEntitiesAreDecoded(): void {
$this->assertSame('<a> & "b"', html_entity_decode_full('&lt;a&gt; &amp; &quot;b&quot;'));
}
public function testSingleQuotesStayEncodedByDefault(): void {
$this->assertSame('&#039;', html_entity_decode_full('&#039;'));
$this->assertSame("'", html_entity_decode_full('&#039;', ENT_QUOTES));
}
public function testUnknownEntitiesAreDropped(): void {
$this->assertSame('ab', html_entity_decode_full('a&bogus;b'));
}
public function testEntityNamesAreCaseSensitive(): void {
$this->assertSame("\u{00E8}\u{00C8}", html_entity_decode_full('&egrave;&Egrave;'));
}
public function testConvertEntity(): void {
$this->assertSame('&#8230;', convert_entity(array('&hellip;', 'hellip')));
$this->assertSame('', convert_entity(array('&bogus;', 'bogus')));
$this->assertSame('&bogus;', convert_entity(array('&bogus;', 'bogus'), FALSE));
}
public function testTextWithoutEntitiesIsUnchanged(): void {
$this->assertSame('Plain & simple? No: plain.', html_entity_decode_full('Plain & simple? No: plain.'));
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Xxx\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use Xxx\Tests\Support\TestCase;
// h() and form_value() in html.lib.php
class HtmlTest extends TestCase {
public static function escapes(): array {
return array(
'tags' => array('<script>alert(1)</script>', '&lt;script&gt;alert(1)&lt;/script&gt;'),
'double quotes' => array('say "hi"', 'say &quot;hi&quot;'),
'single quotes' => array("it's", 'it&#039;s'),
'ampersand' => array('fish & chips', 'fish &amp; chips'),
'plain text' => array('Plain text', 'Plain text'),
'utf-8' => array('café – “quoted”', 'café – “quoted”'),
);
}
#[DataProvider('escapes')]
public function testHEscapesText(string $input, string $expected): void {
$this->assertSame($expected, h($input));
}
#[DataProvider('escapes')]
public function testFormValueEscapesText(string $input, string $expected): void {
$this->assertSame($expected, form_value($input));
}
public function testHKeepsExistingEntities(): void {
$this->assertSame('Wait&hellip; &amp; see', h('Wait&hellip; & see'));
}
public function testFormValueEscapesExistingEntitiesSoThatTheyComeBackUnchanged(): void {
$this->assertSame('Wait&amp;hellip;', form_value('Wait&hellip;'));
$this->assertSame('Wait&hellip;', html_entity_decode(form_value('Wait&hellip;'), ENT_QUOTES, 'UTF-8'));
}
public function testFormValueStopsATextareaFromEnding(): void {
$this->assertSame('&lt;/textarea&gt;&lt;script&gt;', form_value('</textarea><script>'));
}
public function testInvalidUtf8IsReplacedNotDropped(): void {
$this->assertSame("a\u{FFFD}b", h("a\xC3b"));
$this->assertSame("a\u{FFFD}b", form_value("a\xC3b"));
}
public function testNonStringsBecomeStrings(): void {
$this->assertSame('', h(NULL));
$this->assertSame('42', h(42));
$this->assertSame('1', form_value(TRUE));
$this->assertSame('', form_value(NULL));
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// Counting failed logins in session.lib.php
class LoginThrottleTest extends TestCase {
private string $user;
protected function setUp(): void {
parent::setUp();
// A new username and address for each test, so that the files of
// other tests (and other runs) do not count
$this->user = 'Ed-' . bin2hex(random_bytes(4));
$_SERVER['REMOTE_ADDR'] = '198.51.100.' . random_int(1, 254) . '-' . bin2hex(random_bytes(4));
}
protected function tearDown(): void {
foreach (array($this->user, 'other-' . $this->user) as $user) {
foreach (array_keys(login_limits($user)) as $key)
@unlink(login_throttle_file($key));
}
parent::tearDown();
}
private function failLogins(int $times, ?string $user = NULL): void {
for ($i = 0; $i < $times; $i++)
record_login_failure($user ?? $this->user);
}
public function testLimitsArePerUserAndAddressPerAddressAndPerUser(): void {
$address = $_SERVER['REMOTE_ADDR'];
$user = strtolower($this->user);
$this->assertSame(array("user-ip:$user|$address" => 5, "ip:$address" => 20, "user:$user" => 50),
login_limits($this->user));
}
public function testUsernameCaseDoesNotMatter(): void {
$this->assertSame(login_limits(strtoupper($this->user)), login_limits(strtolower($this->user)));
}
public function testThrottleFilesAreHashedOutsideTheWebRoot(): void {
$file = login_throttle_file('ip:192.0.2.1');
$this->assertSame(sys_get_temp_dir() . '/xxx-login-throttle/' . hash('sha256', 'ip:192.0.2.1'), $file);
$this->assertDirectoryExists(dirname($file));
$this->assertSame(0700, fileperms(dirname($file)) & 0777);
}
public function testFourFailuresDoNotBlock(): void {
$this->failLogins(4);
$this->assertSame(0, login_blocked_for($this->user));
}
public function testFiveFailuresBlockForTheWindow(): void {
$this->failLogins(5);
$wait = login_blocked_for($this->user);
$this->assertGreaterThan(LOGIN_WINDOW - 5, $wait);
$this->assertLessThanOrEqual(LOGIN_WINDOW, $wait);
}
public function testEachFailureCountsForEveryKey(): void {
$this->failLogins(2);
foreach (array_keys(login_limits($this->user)) as $key)
$this->assertCount(2, login_failures($key));
}
public function testOldFailuresAreForgotten(): void {
$keys = array_keys(login_limits($this->user));
$old = time() - LOGIN_WINDOW - 1;
file_put_contents(login_throttle_file($keys[0]), json_encode(array_fill(0, 5, $old)));
$this->assertSame(array(), login_failures($keys[0]));
$this->assertSame(0, login_blocked_for($this->user));
}
public function testTheWaitEndsAWindowAfterTheOldestFailure(): void {
$keys = array_keys(login_limits($this->user));
$oldest = time() - 600;
file_put_contents(login_throttle_file($keys[0]), json_encode(array($oldest, time(), time(), time(), time())));
$this->assertSame($oldest + LOGIN_WINDOW - time(), login_blocked_for($this->user));
}
public function testDamagedFilesCountAsNoFailures(): void {
$keys = array_keys(login_limits($this->user));
file_put_contents(login_throttle_file($keys[0]), 'not json');
$this->assertSame(array(), login_failures($keys[0]));
file_put_contents(login_throttle_file($keys[0]), json_encode(array('1', NULL, time())));
$this->assertCount(1, login_failures($keys[0]));
}
public function testTwentyFailuresFromOneAddressBlockEveryUser(): void {
$this->failLogins(4);
for ($i = 0; $i < 4; $i++)
$this->failLogins(4, "user$i-" . $this->user);
$this->assertGreaterThan(0, login_blocked_for('other-' . $this->user));
for ($i = 0; $i < 4; $i++) {
foreach (array_keys(login_limits("user$i-" . $this->user)) as $key)
@unlink(login_throttle_file($key));
}
}
public function testFiftyFailuresForAUserBlockEveryAddress(): void {
$keys = array_keys(login_limits($this->user));
file_put_contents(login_throttle_file($keys[2]), json_encode(array_fill(0, 50, time())));
$_SERVER['REMOTE_ADDR'] = '203.0.113.9-' . bin2hex(random_bytes(4));
$this->assertGreaterThan(0, login_blocked_for($this->user));
}
public function testClearingForgetsOnlyThisUserFromThisAddress(): void {
$this->failLogins(3);
clear_login_failures($this->user);
$keys = array_keys(login_limits($this->user));
$this->assertSame(array(), login_failures($keys[0]));
$this->assertCount(3, login_failures($keys[1]));
$this->assertCount(3, login_failures($keys[2]));
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// sort_options() and get_pager() in navigation.lib.php
class NavigationTest extends TestCase {
protected function setUp(): void {
parent::setUp();
$rows = array();
for ($i = 1; $i <= 12; $i++)
$rows[] = array('id' => $i, 'title' => "Story $i", 'date' => sprintf('2020-01-%02d 10:00:00', $i));
SiteData::install(array('xxx' => $rows));
$GLOBALS['num_stories'] = 5;
open_db('open');
}
public function testSortOptionsLinkEveryOtherSort(): void {
$html = sort_options('/article');
$this->assertStringStartsWith('<div class="options">Sort by: ', $html);
// Newest first is the current sort, so it is not a link
$this->assertStringContainsString('<span>date descending</span> <span class="bar">|</span>', $html);
$this->assertStringNotContainsString('order=desc">' . '<span>date descending', $html);
$this->assertStringContainsString('<a href="/article?sort=date&amp;order=asc"><span>date</span></a>', $html);
$this->assertStringContainsString('<a href="/article?sort=author&amp;order=desc"><span>author z-a</span></a>', $html);
$this->assertStringContainsString('<a href="/article?sort=title&amp;order=asc"><span>title</span></a>', $html);
$this->assertSame(5, substr_count($html, '<a href='));
}
public function testSortOptionsKeepTheOtherArgumentsSafely(): void {
$this->request(array('queue' => '"><script>', 'search' => 'a b&c"<', 'start' => '5x', 'show' => '10', 'sort' => 'title'));
$html = sort_options('/article');
$this->assertStringContainsString('&amp;queue=0&amp;search=a+b%26c%22%3C&amp;start=0&amp;show=10"', $html);
$this->assertStringNotContainsString('<script>', $html);
}
public function testFirstPageHasOnlyAPreviousLink(): void {
$html = get_pager();
$this->assertStringContainsString('<span class="next"></span>', $html);
$this->assertStringContainsString('<a href="/article?start=5">Previous five articles &raquo;</a>', $html);
}
public function testMiddlePageHasBothLinks(): void {
$this->request(array('start' => '5'));
$html = get_pager();
$this->assertStringContainsString('<a href="/article?start=0">&laquo; Next five articles</a>', $html);
$this->assertStringContainsString('<a href="/article?start=10">Previous five articles &raquo;</a>', $html);
}
public function testLastPageHasOnlyANextLink(): void {
$this->request(array('start' => '10'));
$html = get_pager();
$this->assertStringContainsString('&laquo; Next five articles', $html);
$this->assertStringContainsString("<span class=\"prev\"></span>", $html);
}
public function testNextNeverGoesBelowZero(): void {
$this->request(array('start' => '2'));
$this->assertStringContainsString('<a href="/article?start=0">', get_pager());
}
public function testOneArticleIsSingular(): void {
$this->request(array('show' => '1'));
$this->assertStringContainsString('Previous article &raquo;', get_pager());
}
public function testPageSizeInWords(): void {
$this->request(array('show' => '3', 'start' => '3'));
$html = get_pager();
$this->assertStringContainsString('&laquo; Next three articles', $html);
$this->assertStringContainsString('Previous three articles &raquo;', $html);
}
public function testPagerKeepsTheOtherArgumentsSafely(): void {
$this->request(array('start' => '5', 'show' => '5', 'search' => 'Story "1', 'queue' => 'x', 'sort' => 'bad', 'order' => 'bad'));
$html = get_pager();
$this->assertStringContainsString('href="/article?start=0&amp;queue=0&amp;search=Story+%221&amp;show=5&amp;sort=date&amp;order=desc"', $html);
}
public function testEditablePagerStaysOnThePage(): void {
$GLOBALS['editable'] = TRUE;
$this->assertStringContainsString('<a href="?start=5">', get_pager());
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Xxx\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use Xxx\Tests\Support\TestCase;
// number_words() in navigation.lib.php
class NumberWordsTest extends TestCase {
public static function numbers(): array {
return array(
array(0, 'zero'),
array(1, 'one'),
array(5, 'five'),
array(10, 'ten'),
array(13, 'thirteen'),
array(19, 'nineteen'),
array(20, 'twenty'),
array(21, 'twenty-one'),
array(25, 'twenty-five'),
array(50, 'fifty'),
array(99, 'ninety-nine'),
array(100, 'one hundred'),
array(101, 'one hundred and one'),
array(105, 'one hundred and five'),
array(110, 'one hundred and ten'),
array(999, 'nine hundred and ninety-nine'),
array(1000, 'one thousand'),
array(1001, 'one thousand and one'),
array(1099, 'one thousand and ninety-nine'),
array(1100, 'one thousand one hundred'),
array(12345, 'twelve thousand three hundred and forty-five'),
array(100000, 'one hundred thousand'),
array(1000000, 'one million'),
array(2000300, 'two million three hundred'),
array(1000000001, 'one billion and one'),
array(-7, 'minus seven'),
array(-121, 'minus one hundred and twenty-one'),
);
}
#[DataProvider('numbers')]
public function testWords(int $number, string $words): void {
$this->assertSame($words, number_words($number));
}
public function testNumericStringsAreAccepted(): void {
$this->assertSame('forty-two', number_words('42'));
}
// The pager shows between 1 and MAX_SHOW stories
public function testEveryPageSizeHasWords(): void {
for ($n = 1; $n <= MAX_SHOW; $n++)
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)?$/', number_words($n), "for $n");
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\TestCase;
// open_db() and db_fail() in open_db.lib.php
class OpenDbTest extends TestCase {
public function testOpenConnectsWithTheConfiguredSettings(): void {
$this->assertTrue(open_db('open'));
$this->assertInstanceOf(FakeDb::class, $GLOBALS['db']);
$this->assertSame(array(array('db.test', 'test-user', 'test-pass', 'test-db')), FakeDb::$connections);
}
public function testOpenTwiceKeepsTheFirstConnection(): void {
open_db('open');
$first = $GLOBALS['db'];
$this->assertFalse(open_db('open'));
$this->assertSame($first, $GLOBALS['db']);
$this->assertCount(1, FakeDb::$connections);
}
public function testCloseClosesAndForgetsTheConnection(): void {
open_db('open');
$connection = $GLOBALS['db'];
$this->assertTrue(open_db('close'));
$this->assertTrue($connection->closed);
$this->assertNull($GLOBALS['db']);
$this->assertFalse(open_db('close'));
}
public function testOpenAfterCloseConnectsAgain(): void {
open_db('open');
open_db('close');
open_db('open');
$this->assertCount(2, FakeDb::$connections);
}
public function testDbHostDefaultsToLocalhost(): void {
$root = var_export(XXX_ROOT, TRUE);
$result = PhpProcess::runPlain(
"require $root . '/vendor/autoload.php';\n" .
"if (!function_exists('mysqli_report')) { define('MYSQLI_REPORT_OFF', 0); function mysqli_report(\$f) { return TRUE; } }\n" .
"require $root . '/open_db.lib.php';\n" .
"\$db_class = Xxx\\Tests\\Support\\FakeDb::class;\n" .
"open_db('open');\n" .
"echo json_encode(Xxx\\Tests\\Support\\FakeDb::\$connections);",
array('XXX_ENV_FILE' => $this->tmp . '/none'));
$this->assertSame('[["localhost",null,null,null]]', $result['stdout'], $result['stderr']);
}
public function testConnectErrorStopsThePageAndLogsTheDetails(): void {
$result = PhpProcess::run('Xxx\Tests\Support\FakeDb::$connectError = "Access denied for user"; open_db("open"); echo "not reached";');
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $result['stdout']);
$this->assertStringContainsString('xxx: database error at open_db.lib.php:', $result['log']);
$this->assertStringContainsString('connect error (1045) Access denied for user', $result['log']);
$this->assertStringNotContainsString('Access denied', $result['stdout']);
}
public function testDbFailLogsTheCallerAndTheDatabaseError(): void {
$result = PhpProcess::run("open_db('open');\n\$GLOBALS['db']->error = \"Table 'secret' doesn't exist\";\ndb_fail();\necho 'not reached';");
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $result['stdout']);
$this->assertMatchesRegularExpression("/xxx: database error at xxx-php-\\w+:5: Table 'secret' doesn't exist/", $result['log']);
}
public function testDbFailWithoutAConnection(): void {
$result = PhpProcess::run('db_fail();');
$this->assertStringContainsString('no database connection', $result['log']);
}
public function testDbFailWithDetail(): void {
$result = PhpProcess::run('db_fail("custom detail");');
$this->assertStringContainsString(': custom detail', $result['log']);
}
public function testFailedQueryInTheCodeStopsThePage(): void {
$result = PhpProcess::run('Xxx\Tests\Support\FakeDb::on("/COUNT/", FALSE); open_db("open"); get_count(); echo "not reached";');
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $result['stdout']);
$this->assertStringContainsString('database error at get_data.lib.php:', $result['log']);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// Password hashing and upgrades in session.lib.php
class PasswordTest extends TestCase {
protected function setUp(): void {
parent::setUp();
open_db('open');
}
private function addEditor(string $hash): void {
SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => $hash, 'email' => 'ed@example.org');
}
private function storedHash(): string {
return SiteData::$tables['editors'][0]['password'];
}
public function testAlgorithmIsArgon2idWhenAvailable(): void {
$this->assertSame(defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT, password_algorithm());
}
public function testHashIsStoredAndChecked(): void {
$this->addEditor('old');
$hash = password_hash('secret', password_algorithm());
$this->assertTrue(store_password_hash('ed', $hash));
$this->assertSame($hash, $this->storedHash());
$this->assertTrue(password_hash_is_stored('ed', $hash));
$this->assertFalse(password_hash_is_stored('ed', 'other'));
$this->assertFalse(password_hash_is_stored('nobody', $hash));
}
public function testCutHashIsNotStored(): void {
$this->addEditor('old');
SiteData::$passwordLength = 60;
$hash = str_repeat('h', 97);
$this->assertFalse(store_password_hash('ed', $hash));
}
public function testOldMd5CryptHashIsUpgraded(): void {
$old = crypt('secret', '$1$abcdefgh$');
$this->assertTrue(password_verify('secret', $old), 'password_verify() still accepts the old hashes');
$this->addEditor($old);
rehash_password('ed', 'secret', $old);
$new = $this->storedHash();
$this->assertNotSame($old, $new);
$this->assertTrue(password_verify('secret', $new));
$this->assertFalse(password_needs_rehash($new, password_algorithm()));
}
public function testCurrentHashIsLeftAlone(): void {
$hash = password_hash('secret', password_algorithm());
$this->addEditor($hash);
rehash_password('ed', 'secret', $hash);
$this->assertSame($hash, $this->storedHash());
$this->assertSame('', $this->loggedErrors());
}
public function testShortColumnKeepsTheOldHash(): void {
$old = crypt('secret', '$1$abcdefgh$');
$this->addEditor($old);
SiteData::$passwordLength = 50;
rehash_password('ed', 'secret', $old);
$this->assertSame($old, $this->storedHash());
$this->assertStringContainsString('could not store a new password hash for ed; widen editors.password', $this->loggedErrors());
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// get_count() and get_query() in get_data.lib.php
class QueryTest extends TestCase {
protected function setUp(): void {
parent::setUp();
SiteData::install(array(
'xxx' => array(
array('id' => 1, 'title' => 'Apple', 'author' => 'Zed', 'date' => '2020-01-01 10:00:00'),
array('id' => 2, 'title' => 'Banana', 'author' => 'Yan', 'date' => '2020-02-01 10:00:00', 'story' => '<p>A cherry story.</p>'),
array('id' => 3, 'title' => 'Cherry', 'author' => 'Xi', 'date' => '2020-03-01 10:00:00', 'hidden' => '1'),
),
'queue' => array(
array('id' => 10, 'title' => 'Queued', 'date' => '2020-04-01 10:00:00'),
),
));
open_db('open');
}
private function lastQuery(): array {
return end(FakeDb::$log);
}
public function testVisitorsDoNotCountHiddenStories(): void {
$this->assertSame('2', get_count());
$this->assertSame('SELECT COUNT(*) FROM xxx WHERE 1 AND hidden IS NOT TRUE', $this->lastQuery()['sql']);
}
public function testEditorsCountHiddenStories(): void {
$this->asEditor();
$this->assertSame('3', get_count());
$this->assertSame('SELECT COUNT(*) FROM xxx WHERE 1', $this->lastQuery()['sql']);
}
public function testEditorsCountTheQueue(): void {
$this->asEditor();
$this->request(array('queue' => '1'));
$this->assertSame('1', get_count());
}
public function testVisitorsCannotCountTheQueue(): void {
$this->request(array('queue' => '1'));
$this->assertSame('2', get_count());
$this->assertStringContainsString('FROM xxx', $this->lastQuery()['sql']);
}
public function testSearchCountBindsTheTermFourTimes(): void {
$this->request(array('search' => "cherry' OR '1'='1"));
get_count();
$query = $this->lastQuery();
$this->assertSame("SELECT COUNT(*) FROM xxx WHERE (title LIKE ? OR author LIKE ? OR date LIKE ? OR story LIKE ?) AND hidden IS NOT TRUE", $query['sql']);
$this->assertSame(array_fill(0, 4, "%cherry' OR '1'='1%"), $query['params']);
}
public function testSearchCountFindsMatches(): void {
$this->request(array('search' => 'cherry'));
$this->assertSame('1', get_count()); // Banana's story; Cherry is hidden
}
public function testDefaultListIsNewestFirstWithoutHiddenStories(): void {
$GLOBALS['num_stories'] = 5;
$rows = get_query();
$this->assertSame(array('2', '1'), array_column($rows, 'id'));
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE ORDER BY date desc LIMIT 0 , 5', $this->lastQuery()['sql']);
$this->assertSame(array(0, 0), array_column($rows, 'queue_url'));
}
public function testEditorsSeeHiddenStories(): void {
$this->asEditor();
$GLOBALS['num_stories'] = 5;
$this->assertSame(array('3', '2', '1'), array_column(get_query(), 'id'));
}
public function testSortOrderStartAndShow(): void {
$this->request(array('sort' => 'title', 'order' => 'asc', 'start' => '1', 'show' => '1'));
$rows = get_query();
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE ORDER BY title asc LIMIT 1 , 1', $this->lastQuery()['sql']);
$this->assertSame(array('Banana'), array_column($rows, 'title'));
}
public function testBadSortAndOrderValuesFallBack(): void {
$this->request(array('sort' => 'id; DROP TABLE xxx', 'order' => 'desc; --', 'start' => 'x', 'show' => 'y'));
get_query();
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE ORDER BY date desc LIMIT 0 , 1', $this->lastQuery()['sql']);
}
public function testOneStoryById(): void {
$rows = get_query('2');
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE AND id = 2 LIMIT 0, 1', $this->lastQuery()['sql']);
$this->assertSame(array('Banana'), array_column($rows, 'title'));
}
public function testVisitorsCannotOpenAHiddenStory(): void {
$this->assertSame(array(), get_query('3'));
}
public function testEditorsCanOpenAHiddenStory(): void {
$this->asEditor();
$this->assertSame(array('Cherry'), array_column(get_query('3'), 'title'));
$this->assertSame('SELECT * FROM xxx WHERE 1 AND id = 3 LIMIT 0, 1', $this->lastQuery()['sql']);
}
public function testIdIsCastToAnInteger(): void {
get_query('2.9');
$this->assertStringContainsString('AND id = 2 LIMIT', $this->lastQuery()['sql']);
}
public function testNonNumericIdListsStories(): void {
get_query('2 OR 1=1');
$this->assertStringNotContainsString('OR 1=1', $this->lastQuery()['sql']);
$this->assertStringContainsString('ORDER BY', $this->lastQuery()['sql']);
}
public function testSearchListBindsTheTerm(): void {
$this->request(array('search' => '%_'));
get_query();
$query = $this->lastQuery();
$this->assertStringStartsWith('SELECT * FROM xxx WHERE (title LIKE ? OR author LIKE ? OR date LIKE ? OR story LIKE ?) AND hidden IS NOT TRUE ORDER BY', $query['sql']);
$this->assertSame(array_fill(0, 4, '%%_%'), $query['params']);
}
public function testQueueRowsAreMarked(): void {
$this->asEditor();
$this->request(array('queue' => '1'));
$GLOBALS['num_stories'] = 5;
$rows = get_query();
$this->assertSame(array('10'), array_column($rows, 'id'));
$this->assertSame(array(1), array_column($rows, 'queue_url'));
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
namespace Xxx\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use Xxx\Tests\Support\TestCase;
// The functions in get_data.lib.php that read and clean request values
class RequestArgsTest extends TestCase {
protected function setUp(): void {
parent::setUp();
open_db('open');
}
public static function intArgs(): array {
return array(
'whole number' => array('7', 7),
'spaces around' => array(' 7 ', 7),
'negative' => array('-3', 0),
'above the maximum' => array('500', 100),
'zero' => array('0', 0),
'decimal' => array('7.5', NULL),
'trailing text' => array('7abc', NULL),
'leading zero' => array('07', NULL),
'plus sign' => array('+7', 7),
'exponent' => array('1e3', NULL),
'SQL' => array('1; DROP TABLE xxx', NULL),
'empty' => array('', NULL),
);
}
#[DataProvider('intArgs')]
public function testGetIntArg(string $value, ?int $expected): void {
$this->request(array('n' => $value));
$this->assertSame($expected, get_int_arg('n', 0, 100));
}
public function testGetIntArgMissingOrNotAString(): void {
$this->assertNull(get_int_arg('n', 0));
$this->request(array('n' => array('1')));
$this->assertNull(get_int_arg('n', 0));
}
public function testGetIntArgHasNoMaximumByDefault(): void {
$this->request(array('n' => '123456789'));
$this->assertSame(123456789, get_int_arg('n', 0));
}
public function testShowNumComesFromTheRequest(): void {
$this->request(array('show' => '12'));
$this->assertSame(12, get_show_num());
}
public function testShowNumIsCappedAndAtLeastOne(): void {
$this->request(array('show' => '100000'));
$this->assertSame(MAX_SHOW, get_show_num());
$this->request(array('show' => '0'));
$this->assertSame(1, get_show_num());
}
public function testShowNumFallsBackToThePageSetting(): void {
$GLOBALS['num_stories'] = 5;
$this->assertSame(5, get_show_num());
$this->request(array('show' => 'many'));
$this->assertSame(5, get_show_num());
}
public function testShowNumFallsBackToOne(): void {
$this->assertSame(1, get_show_num());
}
public function testStartNum(): void {
$this->assertSame(0, get_start_num());
$this->request(array('start' => '15'));
$this->assertSame(15, get_start_num());
$this->request(array('start' => '-15'));
$this->assertSame(0, get_start_num());
$this->request(array('start' => '15 OR 1=1'));
$this->assertSame(0, get_start_num());
}
public static function sorts(): array {
return array(
array(NULL, 'date'), array('author', 'author'), array('title', 'title'), array('date', 'date'),
array('id', 'date'), array('title; DROP TABLE xxx', 'date'), array('TITLE', 'date'),
);
}
#[DataProvider('sorts')]
public function testSortArgAllowsOnlyKnownColumns(?string $sort, string $expected): void {
if ($sort !== NULL)
$this->request(array('sort' => $sort));
$this->assertSame($expected, get_sort_arg());
}
public function testSortArgIgnoresArrays(): void {
$this->request(array('sort' => array('title')));
$this->assertSame('date', get_sort_arg());
}
public static function orders(): array {
return array(
'date default' => array(array(), 'desc'),
'title default' => array(array('sort' => 'title'), 'asc'),
'author default' => array(array('sort' => 'author'), 'asc'),
'asc' => array(array('order' => 'asc'), 'asc'),
'DESC' => array(array('order' => 'DESC', 'sort' => 'title'), 'DESC'),
'injection' => array(array('order' => 'desc, (SELECT 1)'), 'desc'),
'unknown' => array(array('order' => 'sideways', 'sort' => 'title'), 'asc'),
'array' => array(array('order' => array('asc')), 'desc'),
);
}
#[DataProvider('orders')]
public function testOrderAllowsOnlyAscAndDesc(array $get, string $expected): void {
$this->request($get);
$this->assertSame($expected, get_order());
}
public function testSearchArgs(): void {
$this->assertNull(get_search_args());
$this->request(array('search' => "O'Brien <b>"));
$this->assertSame("O'Brien <b>", get_search_args());
$this->request(array('search' => array('x')));
$this->assertNull(get_search_args());
}
public function testIdMustBeNumeric(): void {
$this->assertNull(get_id());
$this->request(array('id' => 'abc'));
$this->assertNull(get_id());
$this->request(array('id' => '1 OR 1=1'));
$this->assertNull(get_id());
$this->request(array('id' => '42'));
$this->assertSame('42', get_id());
}
public function testIdComesFromPostToo(): void {
$this->request(array(), array('id' => '9'));
$this->assertSame('9', get_id());
}
public function testIdKeepsAGlobalValue(): void {
$GLOBALS['id'] = 5;
$this->request(array('id' => '42'));
$this->assertSame(5, get_id());
}
public function testIdIgnoresArrays(): void {
$this->request(array('id' => array('1')));
$this->assertNull(get_id());
}
public function testQueueIsOffByDefault(): void {
$this->assertFalse(get_queue());
}
public function testQueueComesFromTheRequest(): void {
$this->request(array('queue' => '1'));
$this->assertTrue(get_queue());
$GLOBALS['queue'] = NULL;
$this->request(array('queue' => '0'));
$this->assertFalse(get_queue());
$GLOBALS['queue'] = NULL;
$this->request(array('queue' => 'yes'));
$this->assertFalse(get_queue());
$GLOBALS['queue'] = NULL;
$this->request(array('queue' => array('1')));
$this->assertFalse(get_queue());
}
public function testQueueIsOnForSaveAndSubmit(): void {
foreach (array('save', 'submit') as $action) {
$GLOBALS['action'] = $action;
$this->request(array('queue' => '0'));
$this->assertTrue(get_queue(), $action);
}
}
public function testQueueKeepsAGlobalValue(): void {
$GLOBALS['queue'] = TRUE;
$this->assertTrue(get_queue());
}
public static function tables(): array {
// is_admin, queue, move, action => table
return array(
'visitor reads the live table' => array(FALSE, FALSE, FALSE, 'read', 'xxx'),
'visitor cannot read the queue' => array(FALSE, TRUE, FALSE, 'read', 'xxx'),
'editor reads the queue' => array(TRUE, TRUE, FALSE, 'read', 'queue'),
'editor reads the live table' => array(TRUE, FALSE, FALSE, 'read', 'xxx'),
'visitor post goes to the queue' => array(FALSE, FALSE, FALSE, 'post', 'queue'),
'visitor save goes to the queue' => array(FALSE, FALSE, FALSE, 'save', 'queue'),
'visitor submit goes to the queue' => array(FALSE, FALSE, FALSE, 'submit', 'queue'),
'visitor delete only touches the queue' => array(FALSE, FALSE, TRUE, 'delete', 'queue'),
'editor post goes live' => array(TRUE, TRUE, TRUE, 'post', 'xxx'),
'editor save goes to the queue' => array(TRUE, FALSE, FALSE, 'save', 'queue'),
'editor submit goes to the queue' => array(TRUE, FALSE, FALSE, 'submit', 'queue'),
'editor moving deletes from the queue' => array(TRUE, TRUE, TRUE, 'delete', 'queue'),
'editor deletes from the live table' => array(TRUE, FALSE, FALSE, 'delete', 'xxx'),
'editor deletes from the queue' => array(TRUE, TRUE, FALSE, 'delete', 'queue'),
);
}
#[DataProvider('tables')]
public function testWhichTable(bool $isAdmin, bool $queue, bool $move, string $action, string $table): void {
$GLOBALS['is_admin'] = $isAdmin;
$GLOBALS['queue'] = $queue;
$this->assertSame($table, which_table($move, $action));
}
public function testPostDataHasEveryField(): void {
$this->request(array(), array('title' => 'T', 'author' => 'A', 'email' => 'e@x.org', 'story' => '<p>S</p>',
'comments' => 'C', 'date' => '2020-01-01', 'hidden' => 'on', 'uploaded' => 'pending',
'editor' => 'ed', 'remove_img' => '1', 'id' => '4', 'ignored' => 'x'));
$entry = get_post_data()[0];
$this->assertSame(array('id', 'title', 'author', 'email', 'upload', 'story', 'comments', 'date', 'hidden',
'uploaded', 'editor', 'remove_img', 'queue_url'), array_keys($entry));
$this->assertSame('T', $entry['title']);
$this->assertSame(1, $entry['remove_img']);
$this->assertNull($entry['upload']);
$this->assertSame(0, $entry['queue_url']);
}
public function testPostDataAsStrings(): void {
$this->request(array(), array('title' => array('not', 'a string'), 'remove_img' => '0', 'id' => '4'));
$entry = get_post_data(TRUE)[0];
$this->assertSame('', $entry['title']);
$this->assertSame('', $entry['author']);
$this->assertSame('0', $entry['remove_img']);
$this->assertSame('4', $entry['id']);
foreach ($entry as $value)
$this->assertIsString($value);
}
public function testPostDataEscapedForHtml(): void {
$this->request(array(), array('title' => '<b>"T" & co</b>', 'story' => 'Wait&hellip;'));
$entry = get_post_data(TRUE, TRUE)[0];
$this->assertSame('&lt;b&gt;&quot;T&quot; &amp; co&lt;/b&gt;', $entry['title']);
$this->assertSame('Wait&hellip;', $entry['story']);
}
public function testPostDataQueueUrlFollowsTheQueue(): void {
$GLOBALS['queue'] = TRUE;
$this->assertSame(1, get_post_data()[0]['queue_url']);
}
public function testMapHelpers(): void {
$this->assertSame('', map_string(NULL));
$this->assertSame('', map_string(array(1)));
$this->assertSame('0', map_string(0));
$this->assertSame('1', map_string(TRUE));
$this->assertSame('&lt;a href=&quot;x&quot;&gt;', map_html('<a href="x">'));
$this->assertSame('<a href="x">', unmap_html(map_html('<a href="x">')));
$this->assertSame("it's", unmap_html('it&#039;s'));
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// purify_story() in lib/purify.inc.php and sanitise() in publish/post.lib.php
class SanitiseTest extends TestCase {
public function testPurifyKeepsTheEditorMarkup(): void {
$html = '<p style="text-align: center;">A <strong>b</strong> <em>c</em> <u>d</u> <sup>2</sup><sub>3</sub></p>'
. '<blockquote style="margin-left: 40px;"><p>q</p></blockquote>'
. '<p style="padding-left: 40px;">in</p><ul><li>one</li></ul><ol><li>two</li></ol>'
. '<p><a href="https://example.org/" title="t">link</a> <a href="mailto:a@example.org">mail</a><br /></p><hr />';
// HTML Purifier writes the styles without spaces
$this->assertSame(str_replace(': ', ':', $html), purify_story($html));
}
public function testPurifyRemovesScriptsAndHandlers(): void {
$this->assertSame('<p>hi</p>', purify_story('<p onclick="x()">hi<script>alert(1)</script></p>'));
$this->assertSame('<p>x</p>', purify_story('<p>x<img src="a" onerror="alert(1)" /></p>'));
$this->assertSame('', purify_story('<iframe src="https://example.org/"></iframe><style>p{}</style>'));
}
public function testPurifyRemovesUnsafeLinks(): void {
$this->assertSame('<a>x</a>', purify_story('<a href="javascript:alert(1)">x</a>'));
$this->assertSame('<a>x</a>', purify_story('<a href="data:text/html,hi">x</a>'));
$this->assertSame('<a href="https://example.org/">x</a>', purify_story('<a href="https://example.org/" target="_blank" onclick="y()" id="z">x</a>'));
}
public function testPurifyKeepsOnlySomeStyles(): void {
$this->assertSame('<p style="text-align:right;">x</p>', purify_story('<p style="text-align: right; color: red; position: fixed">x</p>'));
$this->assertSame('<span>x</span>', purify_story('<span style="background: url(javascript:alert(1))">x</span>'));
}
public function testPurifyClosesOpenTags(): void {
$this->assertSame('<p><strong>x</strong></p>', purify_story('<p><strong>x'));
$this->assertSame('', purify_story(NULL));
}
public function testSanitiseLeavesAnEmptyStory(): void {
$this->assertSame('', sanitise(''));
$this->assertNull(sanitise(NULL));
}
// SmartyPants writes numeric entities, and HTML Purifier turns them into characters
public function testNumberRangesGetEnDashesInTextOnly(): void {
$this->assertSame('<p>pages 10–20 and 3–4</p>', sanitise('<p>pages 10-20 and 3--4</p>'));
$this->assertSame('<p><a href="https://example.org/1-2">1–2</a></p>', sanitise('<p><a href="https://example.org/1-2">1-2</a></p>'));
}
public function testDoubleHyphensBecomeEmDashes(): void {
$this->assertSame('<p>a — b and c—d</p>', sanitise('<p>a -- b and c--d</p>'));
}
public function testThreeDotsBecomeAnEllipsis(): void {
$this->assertSame('<p>a.b.c and 1..2</p>', sanitise('<p>a.b.c and 1..2</p>'));
$this->assertSame('<p>Wait… what… now</p>', sanitise('<p>Wait... what. . . now</p>'));
}
public function testQuotesBecomeCurly(): void {
$this->assertSame('<p>“Hi,” she said. It’s ‘fine’.</p>',
sanitise('<p>"Hi," she said. It\'s \'fine\'.</p>'));
}
public function testScriptsAndCommentsAreRemoved(): void {
$this->assertSame('<p>ab</p>', sanitise("<p>a<script type=\"text/javascript\">\nalert('x');\n</script>b</p>"));
$this->assertSame('<p>ab</p>', sanitise("<p>a<!-- a\ncomment -->b</p>"));
}
public function testTheResultIsPurified(): void {
$this->assertSame('<p>x</p>', sanitise('<p onmouseover="steal()">x</p>'));
$this->assertSame('<a>x</a>', sanitise('<a href="javascript:steal()">x</a>'));
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\Mail;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// save_data() in publish/post.lib.php
class SaveDataTest extends TestCase {
private const TABLES = array(
'xxx' => array(array('id' => 1, 'title' => 'Live', 'date' => '2020-01-01 10:00:00', 'story' => '<p>Old.</p>')),
'queue' => array(array('id' => 5, 'title' => 'Waiting', 'date' => '2020-03-01 10:00:00', 'story' => '',
'img' => 'QUEUED IMAGE', 'mime' => 'image/gif')),
);
protected function setUp(): void {
parent::setUp();
SiteData::install(self::TABLES);
open_db('open');
}
// Save as an editor, with $action and the POST fields given
private function save(string $action, array $post, array $get = array()): string {
$this->asEditor();
$GLOBALS['action'] = $action;
$this->request($get, $post + array('title' => 'Title', 'author' => 'Ann', 'story' => '<p>Text.</p>'));
return (string)save_data();
}
private function pendingImage(): string {
$name = bin2hex(random_bytes(16));
file_put_contents(upload_dir() . "/$name", self::png());
$_SESSION['pending_upload'] = $name;
return upload_dir() . "/$name";
}
public function testEditorSavesANewStoryToTheQueue(): void {
$id = $this->save('save', array('email' => 'ann@example.org', 'comments' => 'Note', 'editor' => 'ed',
'hidden' => '1', 'date' => '2021-05-06 07:08:09'));
$this->assertSame('6', $id);
$row = SiteData::find('queue', 6);
$this->assertSame(array('Title', 'Ann', 'ann@example.org', '<p>Text.</p>', 'Note', 'ed', 1, '2021-05-06 07:08:09'),
array($row['title'], $row['author'], $row['email'], $row['story'], $row['comments'], $row['editor'], $row['hidden'], $row['date']));
$this->assertCount(1, SiteData::$tables['xxx']);
}
public function testANewStoryGetsTheDateNow(): void {
$this->save('save', array());
$date = SiteData::find('queue', 6)['date'];
$this->assertEqualsWithDelta(time(), strtotime($date . ' UTC'), 5);
}
public function testEditorUpdatesAQueuedStoryInPlace(): void {
$id = $this->save('save', array('id' => '5', 'title' => 'Changed', 'date' => '2030-01-01 00:00:00'));
$this->assertSame('5', $id);
$this->assertSame('Changed', SiteData::find('queue', 5)['title']);
// An update keeps the date
$this->assertSame('2020-03-01 10:00:00', SiteData::find('queue', 5)['date']);
$update = FakeDb::matching('/^UPDATE queue/')[0];
$this->assertStringEndsWith('WHERE `id` = ? LIMIT 1', $update['sql']);
$this->assertSame(5, end($update['params']));
}
public function testEditorUpdatesALiveStory(): void {
$this->assertSame('1', $this->save('post', array('id' => '1', 'title' => 'Edited')));
$this->assertSame('Edited', SiteData::find('xxx', 1)['title']);
$this->assertCount(1, SiteData::$tables['xxx']);
}
public function testPostingFromTheQueueMovesTheStory(): void {
$id = $this->save('post', array('id' => '5', 'title' => 'Now live', 'date' => '2021-01-01 00:00:00'), array('queue' => '1'));
$this->assertSame('6', $id);
$row = SiteData::find('xxx', 6);
$this->assertSame(array('Now live', '2021-01-01 00:00:00', '<p>Text.</p>', NULL), array($row['title'], $row['date'], $row['story'], $row['img']));
$this->assertNull(SiteData::find('queue', 5));
$this->assertFalse($GLOBALS['queue']);
}
public function testPostingFromTheQueueCanKeepTheQueuedImage(): void {
$this->save('post', array('id' => '5', 'uploaded' => '5'), array('queue' => '1'));
$row = SiteData::find('xxx', 6);
$this->assertSame(array('QUEUED IMAGE', 'image/gif', ''), array($row['img'], $row['mime'], $row['story']));
}
public function testAPendingUploadBecomesTheImage(): void {
$file = $this->pendingImage();
$this->save('save', array('uploaded' => 'pending'));
$row = SiteData::find('queue', 6);
$this->assertSame(array(self::png(), 'image/png', ''), array($row['img'], $row['mime'], $row['story']));
$this->assertFileDoesNotExist($file);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testRemovingTheImageKeepsTheText(): void {
$this->save('save', array('id' => '5', 'remove_img' => '1', 'story' => '<p>Words.</p>'));
$row = SiteData::find('queue', 5);
$this->assertSame(array(NULL, NULL, '<p>Words.</p>'), array($row['img'], $row['mime'], $row['story']));
}
public function testTheStoryIsSanitised(): void {
$this->save('save', array('story' => '<p onclick="x()">a -- b<script>alert(1)</script></p>'));
$this->assertSame('<p>a — b</p>', SiteData::find('queue', 6)['story']);
}
public function testValuesAreBoundAsParameters(): void {
$this->save('save', array('title' => "O'Brien\"; DROP TABLE xxx; --"));
$insert = FakeDb::matching('/^INSERT INTO queue/')[0];
$this->assertStringNotContainsString('DROP', $insert['sql']);
$this->assertContains("O'Brien\"; DROP TABLE xxx; --", $insert['params']);
}
// Visitors always add to the queue, and each story sends mail
public function testVisitorsCanOnlyAddToTheQueue(): void {
$state = $this->tmp . '/state';
file_put_contents($state, serialize(array(
'tables' => self::TABLES,
'post' => array('id' => '1', 'title' => 'Mine', 'author' => 'Vi', 'story' => '<p>Hi.</p>', 'comments' => 'sneaky',
'editor' => 'ed', 'hidden' => '1', 'date' => '1999-01-01 00:00:00'),
'session' => array('captcha_ok' => time()),
)));
$file = var_export($state, TRUE);
$code = <<<PHP
\$state = unserialize(file_get_contents($file));
Xxx\Tests\Support\SiteData::install(\$state['tables']);
open_db('open');
\$action = 'post';
\$_POST = \$_REQUEST = \$state['post'];
\$_SESSION = \$state['session'];
\$id = save_data();
file_put_contents($file, serialize(array('id' => \$id, 'tables' => Xxx\Tests\Support\SiteData::\$tables, 'session' => \$_SESSION, 'action' => \$action)));
PHP;
$result = Mail::run($code, array('UPLOAD_DIR' => $this->tmp . '/uploads'));
$this->assertSame(0, $result['status'], $result['stderr']);
$this->assertSame('', $result['log']);
$saved = unserialize(file_get_contents($state));
$this->assertSame('6', (string)$saved['id']);
$this->assertSame('submit', $saved['action']);
$this->assertSame(self::TABLES['xxx'][0]['title'], $saved['tables']['xxx'][0]['title']);
$row = end($saved['tables']['queue']);
$this->assertSame(array('6', 'Mine', 'original author', 0, '', '<p>Hi.</p>'),
array($row['id'], $row['title'], $row['editor'], $row['hidden'], $row['comments'], $row['story']));
$this->assertNotSame('1999-01-01 00:00:00', $row['date']);
$this->assertSame(array('allow_queue_image' => '6'), $saved['session']);
$this->assertCount(1, $result['mails']);
$this->assertSame('New submission: Mine', $result['mails'][0]['subject']);
$this->assertStringContainsString('Vi has sent the following new submission', $result['mails'][0]['body']);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// CSRF tokens, editor logins and persistent logins in session.lib.php
class SessionTest extends TestCase {
protected function setUp(): void {
parent::setUp();
ini_set('session.save_path', $this->tmp);
ini_set('session.use_cookies', '0');
ini_set('session.cache_limiter', '');
}
protected function tearDown(): void {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
parent::tearDown();
}
public function testCsrfTokenIsRandomAndKept(): void {
$token = csrf_token();
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token);
$this->assertSame($token, csrf_token());
$this->assertSame($token, $_SESSION['csrf_token']);
$_SESSION = array();
$this->assertNotSame($token, csrf_token());
}
public function testCsrfTokenReplacesABadSessionValue(): void {
$_SESSION['csrf_token'] = array('not a string');
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', csrf_token());
}
public function testCsrfField(): void {
$this->assertSame('<input type="hidden" name="csrf_token" value="' . csrf_token() . '" />', csrf_field());
}
public function testCsrfValidAcceptsOnlyTheSessionToken(): void {
$token = csrf_token();
$this->assertTrue(csrf_valid($token));
$this->assertFalse(csrf_valid(strtoupper($token)));
$this->assertFalse(csrf_valid(''));
$this->assertFalse(csrf_valid($token . 'x'));
$this->assertFalse(csrf_valid(array($token)));
}
public function testCsrfValidReadsThePostedToken(): void {
$token = csrf_token();
$this->assertFalse(csrf_valid());
$this->request(array(), array('csrf_token' => $token));
$this->assertTrue(csrf_valid());
$this->request(array('csrf_token' => $token));
$this->assertFalse(csrf_valid(), 'a token in the query string is not enough');
}
public function testCsrfValidFailsWithoutASessionToken(): void {
$this->assertFalse(csrf_valid(''));
$this->assertFalse(csrf_valid('anything'));
}
public function testLoginEditorGivesANewSessionId(): void {
session_start();
$before = session_id();
$_SESSION['csrf_token'] = 'old token';
$_SESSION['referrer'] = '/about';
login_editor('ed');
$this->assertNotSame($before, session_id());
$this->assertSame('ed', $_SESSION['editor']);
$this->assertArrayNotHasKey('csrf_token', $_SESSION);
$this->assertSame('/about', $_SESSION['referrer']);
$this->assertFileDoesNotExist($this->tmp . '/sess_' . $before);
}
public function testPersistentCookieParts(): void {
$this->assertSame(array('', ''), persistent_cookie());
$_COOKIE['PersistentAuth'] = 'ed|key|with|bars';
$this->assertSame(array('ed', 'key|with|bars'), persistent_cookie());
$_COOKIE['PersistentAuth'] = 'nobar';
$this->assertSame(array('nobar', ''), persistent_cookie());
$_COOKIE['PersistentAuth'] = array('ed', 'key');
$this->assertSame(array('', ''), persistent_cookie());
}
public function testPersistentKeyHash(): void {
$this->assertSame(hash('sha256', 'key'), persistent_key_hash('key'));
}
public function testCreatePersistentLoginStoresOnlyAHash(): void {
$this->assertTrue(create_persistent_login('ed'));
$rows = SiteData::$tables['persistent_sessions'];
$this->assertCount(1, $rows);
$this->assertSame('ed', $rows[0]['username']);
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $rows[0]['session']);
$this->assertNull($GLOBALS['db'], 'the connection is closed again');
}
public function testCheckPersistentLoginWithoutACookie(): void {
$this->assertFalse(check_persistent_login());
$this->assertSame(array(), FakeDb::$log);
}
public function testCheckPersistentLoginWithAnIncompleteCookie(): void {
foreach (array('', 'ed', 'ed|', '|key') as $cookie) {
$_COOKIE['PersistentAuth'] = $cookie;
$this->assertFalse(check_persistent_login(), $cookie);
}
$this->assertSame(array(), FakeDb::$log);
}
public function testCheckPersistentLoginWithAWrongKey(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('right'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|wrong';
$this->assertFalse(check_persistent_login());
$this->assertArrayNotHasKey('editor', $_SESSION);
$query = FakeDb::matching('/FROM persistent_sessions/')[0];
$this->assertSame(array('ed', persistent_key_hash('wrong')), $query['params']);
}
public function testCheckPersistentLoginWithAKeyOfAnotherEditor(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ann', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertFalse(check_persistent_login());
}
public function testCheckPersistentLoginLogsInAndSwapsTheKey(): void {
session_start();
$before = session_id();
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertTrue(check_persistent_login());
$this->assertSame('ed', $_SESSION['editor']);
$this->assertNotSame($before, session_id());
$rows = SiteData::$tables['persistent_sessions'];
$this->assertCount(1, $rows);
$this->assertNotSame(persistent_key_hash('key'), $rows[0]['session'], 'the used key is gone');
}
public function testDeletePersistentLoginRemovesThisKeyAndOldKeys(): void {
SiteData::$tables['persistent_sessions'] = array(
array('username' => 'ed', 'session' => persistent_key_hash('mine'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ed', 'session' => persistent_key_hash('other device'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ann', 'session' => persistent_key_hash('old'), 'created' => '2000-01-01 00:00:00'),
array('username' => 'bo', 'session' => 'an unhashed key from before', 'created' => gmdate('Y-m-d H:i:s')),
);
$_COOKIE['PersistentAuth'] = 'ed|mine';
$this->assertTrue(delete_persistent_login());
$this->assertSame(array(persistent_key_hash('other device')), array_column(SiteData::$tables['persistent_sessions'], 'session'));
$this->assertCount(1, FakeDb::matching('/INTERVAL 1 YEAR.*CHAR_LENGTH\(session\) <> 64/s'));
}
public function testDeletePersistentLoginWithoutACookieStillReaps(): void {
delete_persistent_login();
$this->assertCount(0, FakeDb::matching('/WHERE username = \?/'));
$this->assertCount(1, FakeDb::matching('/CHAR_LENGTH/'));
}
// The top of session.lib.php runs on every page; it needs HTTPS and a session
public function testSessionStartsOnlyOverHttps(): void {
$code = '$_SERVER["HTTPS"] = %s; session_id("abc123"); session_start(); $_SESSION["editor"] = "ed"; session_write_close();'
. ' $_COOKIE[session_name()] = "abc123"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $username ?? NULL, session_status() === PHP_SESSION_ACTIVE));';
$ini = array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => '');
$https = PhpProcess::runBare(sprintf($code, '"on"'), array(), $ini);
$this->assertSame('[true,"ed",true]', $https['stdout'], $https['stderr']);
}
public function testNoSessionOverPlainHttp(): void {
$result = PhpProcess::runBare('include XXX_ROOT . "/session.lib.php"; echo json_encode(array($is_admin, session_status() === PHP_SESSION_ACTIVE));',
array(), array('session.save_path' => $this->tmp));
$this->assertSame('[false,false]', $result['stdout'], $result['stderr']);
}
public function testPersistentCookieLogsInOverHttps(): void {
$store = $this->tmp . '/tables.json';
file_put_contents($store, json_encode(array('persistent_sessions' => array(
array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'))))));
$code = 'Xxx\Tests\Support\SiteData::install(json_decode(file_get_contents(' . var_export($store, TRUE) . '), TRUE));'
. ' $_SERVER["HTTPS"] = "on"; $_COOKIE["PersistentAuth"] = "ed|key"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $username ?? NULL));';
$result = PhpProcess::runBare($code, array(), array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => ''));
$this->assertSame('[true,"ed"]', $result['stdout'], $result['stderr']);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// The upload directory and the pending upload in get_data.lib.php and publish/post.lib.php
class UploadTest extends TestCase {
private function pending(string $content = ''): string {
$name = bin2hex(random_bytes(16));
$file = upload_dir() . '/' . $name;
file_put_contents($file, $content === '' ? self::png() : $content);
$_SESSION['pending_upload'] = $name;
return $file;
}
public function testUploadDirIsMadeFromTheConfiguration(): void {
$this->assertSame($this->tmp . '/uploads', upload_dir());
$this->assertDirectoryExists($this->tmp . '/uploads');
$this->assertSame(0700, fileperms($this->tmp . '/uploads') & 0777);
}
public function testUploadDirLosesATrailingSlash(): void {
$this->setEnv('UPLOAD_DIR', $this->tmp . '/other/');
$this->assertSame($this->tmp . '/other', upload_dir());
}
public function testUploadDirDefaultsToTheTempDirectory(): void {
$this->setEnv('UPLOAD_DIR', '');
$this->assertSame(sys_get_temp_dir() . '/xxx-uploads', upload_dir());
}
public function testUploadNamesAre32HexDigits(): void {
$this->assertTrue((bool)is_upload_name(str_repeat('a0', 16)));
$this->assertFalse((bool)is_upload_name(str_repeat('a0', 15)));
$this->assertFalse((bool)is_upload_name(str_repeat('A0', 16)));
$this->assertFalse((bool)is_upload_name('../' . str_repeat('a', 29)));
$this->assertFalse((bool)is_upload_name(str_repeat('a', 32) . "\n"));
$this->assertFalse((bool)is_upload_name(array()));
$this->assertFalse((bool)is_upload_name(NULL));
}
public function testStoreUploadRefusesAFileThatWasNotUploaded(): void {
$file = $this->tmp . '/local';
file_put_contents($file, 'x');
$this->assertFalse(store_upload($file));
$this->assertFileExists($file);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testPendingUploadFindsTheSessionFile(): void {
$file = $this->pending();
$this->assertSame($file, pending_upload());
}
public function testPendingUploadNeedsAValidNameAndAFile(): void {
$this->assertFalse(pending_upload());
$_SESSION['pending_upload'] = '../../etc/passwd';
$this->assertFalse(pending_upload());
$_SESSION['pending_upload'] = str_repeat('b', 32);
$this->assertFalse(pending_upload());
}
public function testPendingUploadUrlHasTheStartOfTheName(): void {
$_SESSION['pending_upload'] = '0123456789abcdef0123456789abcdef';
$this->assertSame('/display?pending=01234567', pending_upload_url());
unset($_SESSION['pending_upload']);
$this->assertSame('/display?pending=', pending_upload_url());
}
public function testMimeTypeComesFromTheImage(): void {
$this->assertSame('image/png', get_mimetype($this->pending()));
$text = $this->tmp . '/text';
file_put_contents($text, 'This is not an image, but it is long enough to read.');
$this->assertNull(get_mimetype($text));
}
public function testSaveUploadedFileUsesThePendingUpload(): void {
$file = $this->pending();
$this->assertFalse(save_uploaded_file());
$_POST['uploaded'] = 'pending';
$this->assertSame($file, save_uploaded_file());
}
public function testSaveUploadedFileIgnoresAFakeUpload(): void {
$file = $this->tmp . '/fake';
file_put_contents($file, self::png());
$_FILES['upload'] = array('tmp_name' => $file, 'name' => 'a.png', 'error' => 0);
$this->assertFalse(save_uploaded_file());
}
public function testPruneDeletesTheSavedFileAndOldUploads(): void {
$saved = $this->pending();
$old = upload_dir() . '/' . str_repeat('c', 32);
$new = upload_dir() . '/' . str_repeat('d', 32);
$other = upload_dir() . '/keep-me';
foreach (array($old, $new, $other) as $file)
file_put_contents($file, 'x');
touch($old, time() - 86401);
touch($other, time() - 86401);
prune_images($saved);
$this->assertFileDoesNotExist($saved);
$this->assertFileDoesNotExist($old);
$this->assertFileExists($new);
$this->assertFileExists($other);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testPruneLeavesFilesOutsideTheUploadDirectory(): void {
$outside = $this->tmp . '/' . str_repeat('e', 32);
file_put_contents($outside, 'x');
prune_images($outside);
$this->assertFileExists($outside);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeRecaptcha;
use Xxx\Tests\Support\TestCase;
// validate_data() in publish/post.lib.php
class ValidateDataTest extends TestCase {
private function validate(array $post, bool $captcha = TRUE): array {
if ($captcha)
$_SESSION['captcha_ok'] = time();
$this->request(array(), $post + array('title' => 'Title', 'author' => 'Ann', 'story' => '<p>x</p>'));
$this->assertTrue(validate_data());
return $GLOBALS['validate'];
}
public function testACompleteStoryPasses(): void {
$this->assertSame(array(), $this->validate(array()));
}
public function testTitleAndAuthorAreNeeded(): void {
$this->assertSame(array('author' => 'You have not submitted an author.', 'title' => 'You have not submitted a title.'),
$this->validate(array('title' => '', 'author' => '')));
}
public function testArraysCountAsEmpty(): void {
$this->assertArrayHasKey('title', $this->validate(array('title' => array('x'))));
}
public function testRemovingTheImageNeedsText(): void {
$this->assertSame(array('story' => "You've tried to remove an image without replacing it with text."),
$this->validate(array('remove_img' => '1', 'story' => '')));
$GLOBALS['validate'] = array();
$this->assertSame(array(), $this->validate(array('remove_img' => '1')));
}
public function testVisitorsNeedTheCaptcha(): void {
FakeRecaptcha::install('{"success": false}');
$this->assertSame(array('captcha' => 'Please complete the captcha.'), $this->validate(array('g-recaptcha-response' => 'r'), FALSE));
}
public function testASolvedCaptchaIsRemembered(): void {
FakeRecaptcha::install('{"success": true}');
$this->assertSame(array(), $this->validate(array('g-recaptcha-response' => 'r'), FALSE));
$this->assertGreaterThan(time() - 5, $_SESSION['captcha_ok']);
}
public function testAnOldCaptchaPassDoesNotCount(): void {
FakeRecaptcha::install('{"success": false}');
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS - 1;
$this->assertArrayHasKey('captcha', $this->validate(array(), FALSE));
}
public function testEditorsNeedNoCaptcha(): void {
FakeRecaptcha::install('{"success": false}');
$this->asEditor();
$this->assertSame(array(), $this->validate(array(), FALSE));
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testUploadErrorsAreReported(): void {
$_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => '', 'error' => UPLOAD_ERR_INI_SIZE);
$this->assertSame(array('upload' => 'There was an error uploading the file (1)'), $this->validate(array()));
}
public function testNoFileIsNotAnError(): void {
$_FILES['upload'] = array('name' => '', 'tmp_name' => '', 'error' => UPLOAD_ERR_NO_FILE);
$this->assertSame(array(), $this->validate(array()));
}
public function testTheUploadMustBeAnImage(): void {
$file = $this->tmp . '/upload';
file_put_contents($file, 'This is not an image, but it is long enough to read.');
$_FILES['upload'] = array('name' => '<b>notes</b>.txt', 'tmp_name' => $file, 'error' => UPLOAD_ERR_OK);
$this->assertSame(array('upload' => "&lt;b&gt;notes&lt;/b&gt;.txt wasn't an image file. Please go back."), $this->validate(array()));
}
public function testAnImageUploadPasses(): void {
$file = $this->tmp . '/upload';
file_put_contents($file, self::png());
$_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => $file, 'error' => UPLOAD_ERR_OK);
$this->assertSame(array(), $this->validate(array()));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
/*
* PHPUnit bootstrap: load the site libraries once, with a fake database
* and a test configuration. PhpProcess runs it again in each subprocess.
*/
require_once __DIR__ . '/environment.php';
// publish/post.lib.php and the files it includes use paths relative to
// publish/. Each unit test runs there too (see TestCase), but PHPUnit
// needs the first directory back to find the test files named on the
// command line.
$cwd = getcwd();
chdir(XXX_ROOT . '/publish');
require_once XXX_ROOT . '/config.lib.php';
require_once XXX_ROOT . '/session.lib.php';
require_once XXX_ROOT . '/get_data.lib.php';
require_once XXX_ROOT . '/navigation.lib.php';
require_once XXX_ROOT . '/publish/post.lib.php';
require_once XXX_ROOT . '/lib/convert_entities.inc.php';
require_once XXX_ROOT . '/lib/smartypants.inc.php';
chdir($cwd);
unset($cwd);
+25
View File
@@ -0,0 +1,25 @@
<?php
/*
* The test environment without the site libraries: a fake database, a
* test configuration and an error log that tests can read. The bootstrap
* loads it, and so do subprocesses that include a library themselves.
*/
if (!defined('XXX_ROOT'))
define('XXX_ROOT', dirname(__DIR__, 2));
require_once XXX_ROOT . '/vendor/autoload.php';
// The site reads the test configuration, never a real .env
putenv('XXX_ENV_FILE=' . XXX_ROOT . '/tests/php/fixtures/test.env');
// Messages that the code logs go to a file that tests can read
define('XXX_TEST_LOG', getenv('XXX_TEST_LOG') ?: sys_get_temp_dir() . '/xxx-test-' . getmypid() . '.log');
ini_set('error_log', XXX_TEST_LOG);
// open_db() makes a FakeDb, so the mysqli extension is not needed
if (!function_exists('mysqli_report')) {
define('MYSQLI_REPORT_OFF', 0);
function mysqli_report($flags) { return TRUE; }
}
$GLOBALS['db_class'] = Xxx\Tests\Support\FakeDb::class;
$_SERVER['REMOTE_ADDR'] = '192.0.2.1';
+8
View File
@@ -0,0 +1,8 @@
<?php
/*
* A sendmail for the tests: PHP's mail() runs it through sendmail_path.
* It appends the message to the file in MAIL_LOG, one JSON line each.
*/
$message = stream_get_contents(STDIN);
file_put_contents(getenv('MAIL_LOG'), json_encode(array('args' => array_slice($argv, 1), 'message' => $message),
JSON_INVALID_UTF8_SUBSTITUTE) . "\n", FILE_APPEND | LOCK_EX);
+60
View File
@@ -0,0 +1,60 @@
<?php
/*
* The router for the page tests, which run the site on PHP's built-in
* web server (see tests/php/Support/PageTestCase.php). For each request
* it sets up the test environment, loads the fake tables from the state
* directory in XXX_PAGE_STATE, and runs the page as Apache does, with
* "/article" for article.php. The tables are saved when the page ends.
*
* The built-in server has no TLS, so a request counts as HTTPS unless it
* has an X-Test-Plain header. The captcha passes when its response is
* "pass".
*/
require dirname(__DIR__) . '/environment.php';
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\FakeRecaptcha;
use Xxx\Tests\Support\SiteData;
// The page file for this request, TRUE when the router answered (404), or
// FALSE for a static file, which the server sends itself
function xxx_page_route() {
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
$file = realpath(XXX_ROOT . $path);
if ($file !== FALSE && is_dir($file))
$file = realpath($file . '/index.php');
else if ($file === FALSE)
$file = realpath(XXX_ROOT . rtrim($path, '/') . '.php');
if ($file === FALSE || !str_starts_with($file, XXX_ROOT . '/') || str_starts_with($file, XXX_ROOT . '/tests/')) {
http_response_code(404);
return TRUE;
}
if (!str_ends_with($file, '.php'))
return FALSE;
$state = getenv('XXX_PAGE_STATE');
$saved = unserialize(file_get_contents("$state/tables.ser"));
FakeDb::$logFile = "$state/queries.jsonl";
SiteData::install($saved['tables']);
SiteData::$passwordLength = $saved['passwordLength'];
register_shutdown_function(function () use ($state) {
file_put_contents("$state/tables.ser", serialize(array('tables' => SiteData::$tables,
'passwordLength' => SiteData::$passwordLength)), LOCK_EX);
});
FakeRecaptcha::install(function ($post) {
return json_encode(array('success' => ($post['response'] ?? '') === 'pass'));
});
if (!isset($_SERVER['HTTP_X_TEST_PLAIN']))
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_NAME'] = 'xxx.org.za';
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_TEST_ADDRESS'] ?? '192.0.2.1';
$_SERVER['SCRIPT_FILENAME'] = $file;
$_SERVER['SCRIPT_NAME'] = $_SERVER['PHP_SELF'] = substr($file, strlen(XXX_ROOT));
chdir(dirname($file));
return $file;
}
if (!is_string($xxx_page = xxx_page_route()))
return $xxx_page;
require $xxx_page;
+10
View File
@@ -0,0 +1,10 @@
# Configuration for the tests (tests/php/bootstrap.php points XXX_ENV_FILE here)
DB_HOST=db.test
DB_USER=test-user
DB_PASS=test-pass
DB_NAME=test-db
RECAPTCHA_SITE_KEY=test-site-key
RECAPTCHA_SECRET=test-secret
GOOGLE_ANALYTICS_ID=
UPLOAD_DIR=
DISPLAY_ERRORS=