Add PHP unit tests and page tests.
Run "composer install", then "composer test".
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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 <b>x</b>', $body);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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… a <strong>bold</strong>\x01 move & 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']);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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=""><script>alert(1)</script>"', $page['body']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=""><script>x</script>"></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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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 <em>title</em></a>', $page);
|
||||
$this->assertStringContainsString('<dd class="author">Ann & "Bo"</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&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&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&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"><b>Mine</b></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="><script>" 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'));
|
||||
}
|
||||
}
|
||||
@@ -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 & <strong>bold</strong>.</p>\n<p>Line one<br />line two</p><p></p><p><b> is text “quoted”</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);
|
||||
}
|
||||
}
|
||||
@@ -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… — “yes”'));
|
||||
}
|
||||
|
||||
public function testNumericEntitiesAreDecoded(): void {
|
||||
$this->assertSame("\u{2013} A", html_entity_decode_full('– A'));
|
||||
}
|
||||
|
||||
public function testTheXmlEntitiesAreDecoded(): void {
|
||||
$this->assertSame('<a> & "b"', html_entity_decode_full('<a> & "b"'));
|
||||
}
|
||||
|
||||
public function testSingleQuotesStayEncodedByDefault(): void {
|
||||
$this->assertSame(''', html_entity_decode_full('''));
|
||||
$this->assertSame("'", html_entity_decode_full(''', 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('èÈ'));
|
||||
}
|
||||
|
||||
public function testConvertEntity(): void {
|
||||
$this->assertSame('…', convert_entity(array('…', '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.'));
|
||||
}
|
||||
}
|
||||
@@ -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>', '<script>alert(1)</script>'),
|
||||
'double quotes' => array('say "hi"', 'say "hi"'),
|
||||
'single quotes' => array("it's", 'it's'),
|
||||
'ampersand' => array('fish & chips', 'fish & 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… & see', h('Wait… & see'));
|
||||
}
|
||||
|
||||
public function testFormValueEscapesExistingEntitiesSoThatTheyComeBackUnchanged(): void {
|
||||
$this->assertSame('Wait&hellip;', form_value('Wait…'));
|
||||
$this->assertSame('Wait…', html_entity_decode(form_value('Wait…'), ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
|
||||
public function testFormValueStopsATextareaFromEnding(): void {
|
||||
$this->assertSame('</textarea><script>', 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));
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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&order=asc"><span>date</span></a>', $html);
|
||||
$this->assertStringContainsString('<a href="/article?sort=author&order=desc"><span>author z-a</span></a>', $html);
|
||||
$this->assertStringContainsString('<a href="/article?sort=title&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('&queue=0&search=a+b%26c%22%3C&start=0&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 »</a>', $html);
|
||||
}
|
||||
|
||||
public function testMiddlePageHasBothLinks(): void {
|
||||
$this->request(array('start' => '5'));
|
||||
$html = get_pager();
|
||||
$this->assertStringContainsString('<a href="/article?start=0">« Next five articles</a>', $html);
|
||||
$this->assertStringContainsString('<a href="/article?start=10">Previous five articles »</a>', $html);
|
||||
}
|
||||
|
||||
public function testLastPageHasOnlyANextLink(): void {
|
||||
$this->request(array('start' => '10'));
|
||||
$html = get_pager();
|
||||
$this->assertStringContainsString('« 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 »', get_pager());
|
||||
}
|
||||
|
||||
public function testPageSizeInWords(): void {
|
||||
$this->request(array('show' => '3', 'start' => '3'));
|
||||
$html = get_pager();
|
||||
$this->assertStringContainsString('« Next three articles', $html);
|
||||
$this->assertStringContainsString('Previous three articles »', $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&queue=0&search=Story+%221&show=5&sort=date&order=desc"', $html);
|
||||
}
|
||||
|
||||
public function testEditablePagerStaysOnThePage(): void {
|
||||
$GLOBALS['editable'] = TRUE;
|
||||
$this->assertStringContainsString('<a href="?start=5">', get_pager());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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…'));
|
||||
$entry = get_post_data(TRUE, TRUE)[0];
|
||||
$this->assertSame('<b>"T" & co</b>', $entry['title']);
|
||||
$this->assertSame('Wait…', $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('<a href="x">', map_html('<a href="x">'));
|
||||
$this->assertSame('<a href="x">', unmap_html(map_html('<a href="x">')));
|
||||
$this->assertSame("it's", unmap_html('it's'));
|
||||
}
|
||||
}
|
||||
@@ -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>'));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' => "<b>notes</b>.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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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=
|
||||
Reference in New Issue
Block a user