Add PHP unit tests and page tests.

Run "composer install", then "composer test".
This commit is contained in:
tim
2026-09-26 03:48:09 +02:00
parent 848c447c5b
commit ed959888b4
42 changed files with 5522 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeRecaptcha;
use Xxx\Tests\Support\TestCase;
// captcha.lib.php. FakeRecaptcha answers in place of Google.
class CaptchaTest extends TestCase {
private function disable(): void {
$this->setEnv('RECAPTCHA_SITE_KEY', '');
}
public function testEnabledWithASiteKey(): void {
$this->assertTrue(captcha_enabled());
$this->disable();
$this->assertFalse(captcha_enabled());
}
public function testScriptAndWidget(): void {
$this->assertSame('<script src="https://www.google.com/recaptcha/api.js" async defer></script>', captcha_script());
$this->assertSame('<div class="g-recaptcha" data-sitekey="test-site-key"></div>', captcha_widget());
}
public function testWidgetEscapesTheSiteKey(): void {
$this->setEnv('RECAPTCHA_SITE_KEY', '"><script>x</script>');
$this->assertSame('<div class="g-recaptcha" data-sitekey="&quot;&gt;&lt;script&gt;x&lt;/script&gt;"></div>', captcha_widget());
}
public function testNothingFromGoogleWithoutASiteKey(): void {
$this->disable();
$this->assertSame('', captcha_script());
$this->assertSame('', captcha_widget());
}
public function testEveryCheckPassesWithoutASiteKey(): void {
$this->disable();
FakeRecaptcha::install(FALSE);
$this->assertTrue(captcha_verify_response());
$this->assertTrue(captcha_check());
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testNoResponseFailsWithoutAskingGoogle(): void {
FakeRecaptcha::install();
$this->assertFalse(captcha_verify_response());
$this->request(array(), array('g-recaptcha-response' => ''));
$this->assertFalse(captcha_verify_response());
$this->request(array(), array('g-recaptcha-response' => array('x')));
$this->assertFalse(captcha_verify_response());
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testSolvedCaptchaPasses(): void {
FakeRecaptcha::install('{"success": true, "hostname": "xxx.org.za"}');
$_SERVER['REMOTE_ADDR'] = '198.51.100.7';
$this->request(array(), array('g-recaptcha-response' => 'token-from-widget'));
$this->assertTrue(captcha_verify_response());
}
// The secret goes in the POST body, never in the URL
public function testRequestSendsTheSecretByPost(): void {
FakeRecaptcha::install();
$_SERVER['REMOTE_ADDR'] = '198.51.100.7';
$this->request(array(), array('g-recaptcha-response' => 'token-from-widget'));
captcha_verify_response();
$this->assertCount(1, FakeRecaptcha::$requests);
$request = FakeRecaptcha::$requests[0];
$this->assertSame('https://www.google.com/recaptcha/api/siteverify', $request['url']);
$this->assertSame('POST', $request['http']['method']);
$this->assertSame(array('secret' => 'test-secret', 'response' => 'token-from-widget', 'remoteip' => '198.51.100.7'), $request['post']);
$this->assertStringContainsString('application/x-www-form-urlencoded', $request['http']['header']);
$this->assertSame(10, $request['http']['timeout']);
}
public function testUnsolvedCaptchaFails(): void {
FakeRecaptcha::install('{"success": false, "error-codes": ["invalid-input-response"]}');
$this->request(array(), array('g-recaptcha-response' => 'bad'));
$this->assertFalse(captcha_verify_response());
}
public function testOddRepliesFail(): void {
$this->request(array(), array('g-recaptcha-response' => 'token'));
foreach (array('', 'not json', '[]', '"success"', '{"success": 0}', '{}') as $reply) {
FakeRecaptcha::install($reply);
$this->assertFalse(captcha_verify_response(), $reply);
}
}
public function testUnreachableServerFailsAndIsLogged(): void {
FakeRecaptcha::install(FALSE);
$this->request(array(), array('g-recaptcha-response' => 'token'));
$this->assertFalse(captcha_verify_response());
$this->assertStringContainsString('xxx: could not reach the reCAPTCHA server', $this->loggedErrors());
}
public function testMissingSecretFailsAndIsLogged(): void {
$this->setEnv('RECAPTCHA_SECRET', '');
FakeRecaptcha::install();
$this->request(array(), array('g-recaptcha-response' => 'token'));
$this->assertFalse(captcha_verify_response());
$this->assertSame(array(), FakeRecaptcha::$requests);
$this->assertStringContainsString('RECAPTCHA_SECRET is not set', $this->loggedErrors());
}
public function testPassLastsHalfAnHour(): void {
$this->assertFalse(captcha_passed());
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS + 5;
$this->assertTrue(captcha_passed());
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS - 1;
$this->assertFalse(captcha_passed());
}
public function testCheckRemembersASolvedCaptcha(): void {
FakeRecaptcha::install();
$this->request(array(), array('g-recaptcha-response' => 'token'));
$this->assertTrue(captcha_check());
$this->assertEqualsWithDelta(time(), $_SESSION['captcha_ok'], 2);
// The next check needs no new answer from Google
FakeRecaptcha::install(FALSE);
$this->request();
$this->assertTrue(captcha_check());
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testCheckFailsWithoutAPassOrAnAnswer(): void {
FakeRecaptcha::install();
$this->assertFalse(captcha_check());
$this->assertArrayNotHasKey('captcha_ok', $_SESSION);
}
public function testUsedEndsThePass(): void {
$_SESSION['captcha_ok'] = time();
captcha_used();
$this->assertFalse(captcha_passed());
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\TestCase;
/*
* config() in config.lib.php. It reads the .env file once per request,
* and config.lib.php calls it as soon as it loads, so each test runs
* config.lib.php in a new PHP process.
*/
class ConfigTest extends TestCase {
/*
* Load config.lib.php with $content as the .env file and return
* config($name, $default) for each array($name, $default) in $calls.
*/
private function config(string $content, array $calls, array $env = array(), string $between = ''): array {
file_put_contents($this->tmp . '/env', $content);
$code = 'require ' . var_export(XXX_ROOT . '/config.lib.php', TRUE) . ";\n\$out = array();\n";
foreach ($calls as $i => $call) {
if ($i === 1)
$code .= $between;
$code .= '$out[] = config(' . var_export($call[0], TRUE) . ', ' . var_export($call[1] ?? NULL, TRUE) . ");\n";
}
$code .= 'echo json_encode($out);';
$result = PhpProcess::runPlain($code, $env + array('XXX_ENV_FILE' => $this->tmp . '/env'));
$this->assertSame('', $result['stderr']);
return json_decode($result['stdout'], TRUE);
}
public function testReadsNameValueLines(): void {
$this->assertSame(array('example.org', 'stories'),
$this->config("DB_HOST=example.org\nDB_NAME=stories\n", array(array('DB_HOST'), array('DB_NAME'))));
}
public function testSkipsCommentsBlankLinesAndLinesWithoutEquals(): void {
$this->assertSame(array(NULL, NULL, 'real'),
$this->config("# DB_HOST=commented\n\n \nnot a setting\nDB_USER=real\n",
array(array('DB_HOST'), array('not a setting'), array('DB_USER'))));
}
public function testRemovesMatchingQuotesAndSpaces(): void {
$this->assertSame(array('double quoted', 'single quoted', "\"unbalanced'", '', '"x'),
$this->config("A = \"double quoted\" \nB='single quoted'\nC=\"unbalanced'\nD=\"\"\nE=\"x\n",
array(array('A'), array('B'), array('C'), array('D'), array('E'))));
}
public function testKeepsEqualsSignsInValues(): void {
$this->assertSame(array('a=b==c'), $this->config("DB_PASS=a=b==c\n", array(array('DB_PASS'))));
}
public function testAcceptsExportPrefix(): void {
$this->assertSame(array('exported'), $this->config("export DB_HOST=exported\n", array(array('DB_HOST'))));
}
public function testTheLastOfTwoLinesWins(): void {
$this->assertSame(array('second'), $this->config("KEY=first\nKEY=second\n", array(array('KEY'))));
}
public function testEnvironmentBeatsTheFile(): void {
$this->assertSame(array('from-environment'),
$this->config("DB_HOST=from-file\n", array(array('DB_HOST')), array('DB_HOST' => 'from-environment')));
}
public function testDefaultWhenMissing(): void {
$this->assertSame(array(NULL, 'fallback'),
$this->config("OTHER=1\n", array(array('MISSING'), array('MISSING', 'fallback'))));
}
public function testEmptyValueIsNotTheDefault(): void {
$this->assertSame(array(''), $this->config("EMPTY=\n", array(array('EMPTY', 'fallback'))));
}
public function testMissingFileGivesDefaults(): void {
$result = PhpProcess::runPlain('require ' . var_export(XXX_ROOT . '/config.lib.php', TRUE) . '; echo config("DB_HOST", "fallback");',
array('XXX_ENV_FILE' => $this->tmp . '/does-not-exist'));
$this->assertSame('fallback', $result['stdout']);
}
public function testFileIsReadOnlyOnce(): void {
$this->assertSame(array('before', 'before'),
$this->config("KEY=before\n", array(array('KEY'), array('KEY')), array(),
'file_put_contents(getenv("XXX_ENV_FILE"), "KEY=after\n");'));
}
public function testDisplayErrorsIsOffUnlessConfigured(): void {
$code = 'require ' . var_export(XXX_ROOT . '/config.lib.php', TRUE) . '; echo ini_get("display_errors"), "|", ini_get("log_errors");';
file_put_contents($this->tmp . '/env', "DISPLAY_ERRORS=\n");
$this->assertSame('|1', PhpProcess::runPlain($code, array('XXX_ENV_FILE' => $this->tmp . '/env'))['stdout']);
file_put_contents($this->tmp . '/env', "DISPLAY_ERRORS=stderr\n");
$this->assertSame('stderr|1', PhpProcess::runPlain($code, array('XXX_ENV_FILE' => $this->tmp . '/env'))['stdout']);
file_put_contents($this->tmp . '/env', "OTHER=1\n");
$this->assertSame('0|1', PhpProcess::runPlain($code, array('XXX_ENV_FILE' => $this->tmp . '/env'))['stdout']);
}
public function testTheTestsUseTheTestFile(): void {
$this->assertSame('test-site-key', config('RECAPTCHA_SITE_KEY'));
}
public function testExampleFileListsEverySettingTheCodeReads(): void {
$code = implode('', array_map('file_get_contents', array_merge(
glob(XXX_ROOT . '/*.php'), glob(XXX_ROOT . '/lib/*.php'), glob(XXX_ROOT . '/publish/*.php'))));
preg_match_all("/config\\('([A-Z_]+)'/", $code, $used);
$example = file_get_contents(XXX_ROOT . '/.env.example');
$this->assertNotEmpty($used[1]);
foreach (array_unique($used[1]) as $name)
$this->assertMatchesRegularExpression("/^$name=/m", $example, "$name is missing from .env.example");
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// delete_story() in publish/post.lib.php
class DeleteStoryTest extends TestCase {
protected function setUp(): void {
parent::setUp();
SiteData::install(array('xxx' => array(array('id' => 1), array('id' => 2)),
'queue' => array(array('id' => 1), array('id' => 3))));
open_db('open');
}
private function ids(string $table): array {
return array_map('intval', array_column(SiteData::$tables[$table], 'id'));
}
public function testVisitorsCannotDelete(): void {
$this->assertSame('', delete_story(1));
$this->assertSame(array(), FakeDb::matching('/^DELETE/'));
}
public function testEditorsDeleteFromTheLiveTable(): void {
$this->asEditor();
$this->assertSame('Article has been deleted.', delete_story(1));
$this->assertSame(array(2), $this->ids('xxx'));
$this->assertSame(array(1, 3), $this->ids('queue'));
}
public function testEditorsDeleteFromTheQueue(): void {
$this->asEditor();
$this->request(array('queue' => '1'));
$this->assertSame('Article has been deleted.', delete_story(3));
$this->assertSame(array(1), $this->ids('queue'));
$this->assertSame(array(1, 2), $this->ids('xxx'));
}
public function testAMoveDeletesTheQueueCopyQuietly(): void {
$this->asEditor();
$this->assertSame('', delete_story(1, TRUE));
$this->assertSame(array(3), $this->ids('queue'));
$this->assertSame(array(1, 2), $this->ids('xxx'));
}
public function testTheIdIsBoundAsAnInteger(): void {
$this->asEditor();
delete_story('2 OR 1=1');
$query = FakeDb::matching('/^DELETE/')[0];
$this->assertSame('DELETE FROM xxx WHERE `id` = ? LIMIT 1', $query['sql']);
$this->assertSame(array(2), $query['params']);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// display_stories() in get_data.lib.php
class DisplayStoriesTest extends TestCase {
protected function setUp(): void {
parent::setUp();
$GLOBALS['num_stories'] = 5;
SiteData::install(array(
'xxx' => array(
array('id' => 1, 'title' => 'First <em>title</em>', 'author' => 'Ann & "Bo"', 'date' => '2020-01-02 03:04:05',
'story' => '<p onclick="steal()">One <script>alert(1)</script>hundred</p>'),
array('id' => 2, 'title' => 'Second', 'author' => 'Cy', 'date' => '2020-01-01 10:00:00', 'story' => '<p>Two</p>'),
array('id' => 3, 'title' => 'Picture', 'author' => 'Di', 'date' => '2019-12-31 10:00:00', 'story' => '',
'img' => 'PNG', 'mime' => 'image/png', 'hidden' => '1'),
),
'queue' => array(
array('id' => 20, 'title' => 'Waiting', 'author' => 'Ed', 'date' => '2020-02-01 10:00:00'),
),
));
open_db('open');
}
public function testListsStoriesWithEscapedFields(): void {
$page = display_stories();
$this->assertStringStartsWith('<ul class="article-list">', $page);
$this->assertSame(2, substr_count($page, '<li class="box">'));
$this->assertStringContainsString('<a href="/article?id=1">First &lt;em&gt;title&lt;/em&gt;</a>', $page);
$this->assertStringContainsString('<dd class="author">Ann &amp; &quot;Bo&quot;</dd>', $page);
$this->assertStringNotContainsString('<em>title</em>', $page);
}
public function testStoryHtmlIsPurified(): void {
$page = display_stories();
$this->assertStringContainsString('<p>One hundred</p>', $page);
$this->assertStringNotContainsString('onclick', $page);
$this->assertStringNotContainsString('alert', $page);
}
public function testDatesAreShownWithoutSecondsAndMarkedUp(): void {
$page = display_stories();
$datetime = date('Y-m-d\TH:i:sP', strtotime('2020-01-02 03:04:05'));
$this->assertStringContainsString('<time datetime="' . $datetime . '">2020-01-02 03:04</time>', $page);
}
public function testHiddenStoriesAreLeftOutForVisitors(): void {
$this->assertStringNotContainsString('Picture', display_stories());
}
public function testEditorsSeeHiddenStoriesMarkedAndEditLinks(): void {
$this->asEditor();
$page = display_stories();
$this->assertStringContainsString('<span class="right red">[hidden]</span>', $page);
$this->assertStringContainsString('[<a href="/publish/edit?id=3&amp;queue=0">edit</a>]', $page);
}
public function testImagesAreServedByDisplay(): void {
$this->asEditor();
$this->assertStringContainsString('<img src="/display?id=3" alt="Picture" />', display_stories());
}
public function testEditableListLinksTitlesToTheEditor(): void {
$this->asEditor();
$GLOBALS['editable'] = TRUE;
$this->request(array('queue' => '1'));
$page = display_stories();
$this->assertStringContainsString('<a href="/publish/edit?id=20&amp;queue=1">Waiting</a>', $page);
$this->assertStringNotContainsString('[<a href', $page);
}
public function testEditableImagesKeepTheQueue(): void {
$this->asEditor();
$GLOBALS['editable'] = TRUE;
$page = display_stories('3');
$this->assertStringContainsString('<img src="/display?id=3&amp;queue=0" alt="Picture" />', $page);
}
public function testOneStoryHasNoPager(): void {
$page = display_stories('2');
$this->assertSame(1, substr_count($page, '<li class="box">'));
$this->assertStringNotContainsString('class="pager"', $page);
}
public function testSeveralStoriesHaveAPager(): void {
$this->assertStringContainsString('<div class="pager">', display_stories());
}
public function testNoStories(): void {
$page = display_stories('99');
$this->assertStringContainsString('<p>No articles found.</p>', $page);
$this->assertStringNotContainsString('Try searching again', $page);
}
public function testNoSearchResults(): void {
$this->request(array('search' => 'nothing like this'));
$page = display_stories();
$this->assertStringContainsString('<p>No articles found.</p>', $page);
$this->assertStringContainsString('Try searching again', $page);
}
public function testPreviewShowsThePostedStoryWithoutLinks(): void {
$GLOBALS['action'] = 'preview';
$this->request(array(), array('title' => '<b>Mine</b>', 'author' => 'Me', 'story' => '<p>My <i>story</i></p><script>x()</script>'));
$page = display_stories();
$this->assertStringContainsString('<h3 class="articletitle">&lt;b&gt;Mine&lt;/b&gt;</h3>', $page);
$this->assertStringContainsString('<dd class="date">Awaiting posting</dd>', $page);
$this->assertStringContainsString('<p>My <i>story</i></p>', $page);
$this->assertStringNotContainsString('x()', $page);
}
public function testPreviewShowsAPendingImage(): void {
$GLOBALS['action'] = 'preview';
$GLOBALS['file_src'] = '/display?pending="><script>';
$this->request(array(), array('title' => 'Pic', 'author' => 'Me'));
$this->assertStringContainsString('<img src="/display?pending=&quot;&gt;&lt;script&gt;" alt="Pic" />', display_stories());
}
public function testVisitorSubmitShowsTheirStory(): void {
$GLOBALS['action'] = 'submit';
$this->request(array(), array('title' => 'Sent', 'author' => 'Me', 'story' => '<p>Done</p>'));
$page = display_stories();
$this->assertMatchesRegularExpression('/<dd class="date">Posted \d{4}-\d\d-\d\d \d\d:\d\d<\/dd>/', $page);
$this->assertStringContainsString('<h3 class="articletitle">Sent</h3>', $page);
}
public function testVisitorSeesTheImageTheySubmittedFromTheQueue(): void {
$GLOBALS['action'] = 'submit';
$_SESSION['allow_queue_image'] = '20';
$this->request(array(), array('title' => 'Sent', 'author' => 'Me', 'uploaded' => 'yes'));
$this->assertStringContainsString('<img src="/display?id=20" alt="Sent" />', display_stories());
}
// An editor's submit saves to the queue, and the page shows the saved row
public function testEditorSubmitShowsTheSavedStoryFromTheQueue(): void {
$this->asEditor();
$GLOBALS['action'] = 'submit';
$page = display_stories('20');
$this->assertStringContainsString('Waiting', $page);
$this->assertStringContainsString('<p>No articles found.</p>', display_stories('2'));
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\Mail;
use Xxx\Tests\Support\TestCase;
// send_email() in publish/post.lib.php
class EmailTest extends TestCase {
private function send(string $title, string $author, string $story): array {
$result = Mail::run('send_email(' . var_export($title, TRUE) . ', ' . var_export($author, TRUE) . ', ' . var_export($story, TRUE) . ');');
$this->assertSame(0, $result['status'], $result['stderr']);
$this->assertCount(1, $result['mails'], $result['stderr']);
$this->assertSame('', $result['log']);
return $result['mails'][0];
}
public function testGoesToTheEditorsFromTheQueue(): void {
$mail = $this->send('A title', 'Ann', '<p>Hello.</p>');
$this->assertSame('editor@xxx.org.za', $mail['headers']['to']);
$this->assertSame('x.x.x Queue <editor@xxx.org.za>', $mail['headers']['from']);
$this->assertSame('New submission: A title', $mail['subject']);
}
public function testSubjectKeepsNonAsciiTitles(): void {
$this->assertSame('New submission: Ōmega café', $this->send('Ōmega café', 'Ann', '<p>x</p>')['subject']);
}
public function testBodyIsPlainTextOfTheStory(): void {
$mail = $this->send('T', 'Ann', "<p>First &amp; <strong>bold</strong>.</p>\n<p>Line one<br />line two</p><p></p><p>&lt;b&gt; is text &#8220;quoted&#8221;</p>");
$this->assertSame("Please review the queue. Ann has sent the following new submission:\n\n"
. "First & bold.\n\nLine one\nline two\n\n<b> is text “quoted”"
. "\n\nhttp://xxx.org.za/publish/edit", trim($mail['body']));
}
public function testLongLinesAreWrapped(): void {
$body = $this->send('T', 'Ann', '<p>' . str_repeat('word ', 40) . '</p>')['body'];
foreach (explode("\n", trim($body)) as $line)
$this->assertLessThanOrEqual(75, strlen($line));
}
public function testImageSubmissionSaysSo(): void {
$body = $this->send('T', 'Ann', '')['body'];
$this->assertStringContainsString("new submission:\n\n<Submission is an image. Review on the site.>", $body);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// html_entity_decode_full() and convert_entity() in lib/convert_entities.inc.php
class EntitiesTest extends TestCase {
public function testNamedEntitiesAreDecoded(): void {
$this->assertSame("Wait\u{2026} \u{2014} \u{201C}yes\u{201D}", html_entity_decode_full('Wait&hellip; &mdash; &ldquo;yes&rdquo;'));
}
public function testNumericEntitiesAreDecoded(): void {
$this->assertSame("\u{2013} A", html_entity_decode_full('&#8211; &#x41;'));
}
public function testTheXmlEntitiesAreDecoded(): void {
$this->assertSame('<a> & "b"', html_entity_decode_full('&lt;a&gt; &amp; &quot;b&quot;'));
}
public function testSingleQuotesStayEncodedByDefault(): void {
$this->assertSame('&#039;', html_entity_decode_full('&#039;'));
$this->assertSame("'", html_entity_decode_full('&#039;', ENT_QUOTES));
}
public function testUnknownEntitiesAreDropped(): void {
$this->assertSame('ab', html_entity_decode_full('a&bogus;b'));
}
public function testEntityNamesAreCaseSensitive(): void {
$this->assertSame("\u{00E8}\u{00C8}", html_entity_decode_full('&egrave;&Egrave;'));
}
public function testConvertEntity(): void {
$this->assertSame('&#8230;', convert_entity(array('&hellip;', 'hellip')));
$this->assertSame('', convert_entity(array('&bogus;', 'bogus')));
$this->assertSame('&bogus;', convert_entity(array('&bogus;', 'bogus'), FALSE));
}
public function testTextWithoutEntitiesIsUnchanged(): void {
$this->assertSame('Plain & simple? No: plain.', html_entity_decode_full('Plain & simple? No: plain.'));
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Xxx\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use Xxx\Tests\Support\TestCase;
// h() and form_value() in html.lib.php
class HtmlTest extends TestCase {
public static function escapes(): array {
return array(
'tags' => array('<script>alert(1)</script>', '&lt;script&gt;alert(1)&lt;/script&gt;'),
'double quotes' => array('say "hi"', 'say &quot;hi&quot;'),
'single quotes' => array("it's", 'it&#039;s'),
'ampersand' => array('fish & chips', 'fish &amp; chips'),
'plain text' => array('Plain text', 'Plain text'),
'utf-8' => array('café – “quoted”', 'café – “quoted”'),
);
}
#[DataProvider('escapes')]
public function testHEscapesText(string $input, string $expected): void {
$this->assertSame($expected, h($input));
}
#[DataProvider('escapes')]
public function testFormValueEscapesText(string $input, string $expected): void {
$this->assertSame($expected, form_value($input));
}
public function testHKeepsExistingEntities(): void {
$this->assertSame('Wait&hellip; &amp; see', h('Wait&hellip; & see'));
}
public function testFormValueEscapesExistingEntitiesSoThatTheyComeBackUnchanged(): void {
$this->assertSame('Wait&amp;hellip;', form_value('Wait&hellip;'));
$this->assertSame('Wait&hellip;', html_entity_decode(form_value('Wait&hellip;'), ENT_QUOTES, 'UTF-8'));
}
public function testFormValueStopsATextareaFromEnding(): void {
$this->assertSame('&lt;/textarea&gt;&lt;script&gt;', form_value('</textarea><script>'));
}
public function testInvalidUtf8IsReplacedNotDropped(): void {
$this->assertSame("a\u{FFFD}b", h("a\xC3b"));
$this->assertSame("a\u{FFFD}b", form_value("a\xC3b"));
}
public function testNonStringsBecomeStrings(): void {
$this->assertSame('', h(NULL));
$this->assertSame('42', h(42));
$this->assertSame('1', form_value(TRUE));
$this->assertSame('', form_value(NULL));
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// Counting failed logins in session.lib.php
class LoginThrottleTest extends TestCase {
private string $user;
protected function setUp(): void {
parent::setUp();
// A new username and address for each test, so that the files of
// other tests (and other runs) do not count
$this->user = 'Ed-' . bin2hex(random_bytes(4));
$_SERVER['REMOTE_ADDR'] = '198.51.100.' . random_int(1, 254) . '-' . bin2hex(random_bytes(4));
}
protected function tearDown(): void {
foreach (array($this->user, 'other-' . $this->user) as $user) {
foreach (array_keys(login_limits($user)) as $key)
@unlink(login_throttle_file($key));
}
parent::tearDown();
}
private function failLogins(int $times, ?string $user = NULL): void {
for ($i = 0; $i < $times; $i++)
record_login_failure($user ?? $this->user);
}
public function testLimitsArePerUserAndAddressPerAddressAndPerUser(): void {
$address = $_SERVER['REMOTE_ADDR'];
$user = strtolower($this->user);
$this->assertSame(array("user-ip:$user|$address" => 5, "ip:$address" => 20, "user:$user" => 50),
login_limits($this->user));
}
public function testUsernameCaseDoesNotMatter(): void {
$this->assertSame(login_limits(strtoupper($this->user)), login_limits(strtolower($this->user)));
}
public function testThrottleFilesAreHashedOutsideTheWebRoot(): void {
$file = login_throttle_file('ip:192.0.2.1');
$this->assertSame(sys_get_temp_dir() . '/xxx-login-throttle/' . hash('sha256', 'ip:192.0.2.1'), $file);
$this->assertDirectoryExists(dirname($file));
$this->assertSame(0700, fileperms(dirname($file)) & 0777);
}
public function testFourFailuresDoNotBlock(): void {
$this->failLogins(4);
$this->assertSame(0, login_blocked_for($this->user));
}
public function testFiveFailuresBlockForTheWindow(): void {
$this->failLogins(5);
$wait = login_blocked_for($this->user);
$this->assertGreaterThan(LOGIN_WINDOW - 5, $wait);
$this->assertLessThanOrEqual(LOGIN_WINDOW, $wait);
}
public function testEachFailureCountsForEveryKey(): void {
$this->failLogins(2);
foreach (array_keys(login_limits($this->user)) as $key)
$this->assertCount(2, login_failures($key));
}
public function testOldFailuresAreForgotten(): void {
$keys = array_keys(login_limits($this->user));
$old = time() - LOGIN_WINDOW - 1;
file_put_contents(login_throttle_file($keys[0]), json_encode(array_fill(0, 5, $old)));
$this->assertSame(array(), login_failures($keys[0]));
$this->assertSame(0, login_blocked_for($this->user));
}
public function testTheWaitEndsAWindowAfterTheOldestFailure(): void {
$keys = array_keys(login_limits($this->user));
$oldest = time() - 600;
file_put_contents(login_throttle_file($keys[0]), json_encode(array($oldest, time(), time(), time(), time())));
$this->assertSame($oldest + LOGIN_WINDOW - time(), login_blocked_for($this->user));
}
public function testDamagedFilesCountAsNoFailures(): void {
$keys = array_keys(login_limits($this->user));
file_put_contents(login_throttle_file($keys[0]), 'not json');
$this->assertSame(array(), login_failures($keys[0]));
file_put_contents(login_throttle_file($keys[0]), json_encode(array('1', NULL, time())));
$this->assertCount(1, login_failures($keys[0]));
}
public function testTwentyFailuresFromOneAddressBlockEveryUser(): void {
$this->failLogins(4);
for ($i = 0; $i < 4; $i++)
$this->failLogins(4, "user$i-" . $this->user);
$this->assertGreaterThan(0, login_blocked_for('other-' . $this->user));
for ($i = 0; $i < 4; $i++) {
foreach (array_keys(login_limits("user$i-" . $this->user)) as $key)
@unlink(login_throttle_file($key));
}
}
public function testFiftyFailuresForAUserBlockEveryAddress(): void {
$keys = array_keys(login_limits($this->user));
file_put_contents(login_throttle_file($keys[2]), json_encode(array_fill(0, 50, time())));
$_SERVER['REMOTE_ADDR'] = '203.0.113.9-' . bin2hex(random_bytes(4));
$this->assertGreaterThan(0, login_blocked_for($this->user));
}
public function testClearingForgetsOnlyThisUserFromThisAddress(): void {
$this->failLogins(3);
clear_login_failures($this->user);
$keys = array_keys(login_limits($this->user));
$this->assertSame(array(), login_failures($keys[0]));
$this->assertCount(3, login_failures($keys[1]));
$this->assertCount(3, login_failures($keys[2]));
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// sort_options() and get_pager() in navigation.lib.php
class NavigationTest extends TestCase {
protected function setUp(): void {
parent::setUp();
$rows = array();
for ($i = 1; $i <= 12; $i++)
$rows[] = array('id' => $i, 'title' => "Story $i", 'date' => sprintf('2020-01-%02d 10:00:00', $i));
SiteData::install(array('xxx' => $rows));
$GLOBALS['num_stories'] = 5;
open_db('open');
}
public function testSortOptionsLinkEveryOtherSort(): void {
$html = sort_options('/article');
$this->assertStringStartsWith('<div class="options">Sort by: ', $html);
// Newest first is the current sort, so it is not a link
$this->assertStringContainsString('<span>date descending</span> <span class="bar">|</span>', $html);
$this->assertStringNotContainsString('order=desc">' . '<span>date descending', $html);
$this->assertStringContainsString('<a href="/article?sort=date&amp;order=asc"><span>date</span></a>', $html);
$this->assertStringContainsString('<a href="/article?sort=author&amp;order=desc"><span>author z-a</span></a>', $html);
$this->assertStringContainsString('<a href="/article?sort=title&amp;order=asc"><span>title</span></a>', $html);
$this->assertSame(5, substr_count($html, '<a href='));
}
public function testSortOptionsKeepTheOtherArgumentsSafely(): void {
$this->request(array('queue' => '"><script>', 'search' => 'a b&c"<', 'start' => '5x', 'show' => '10', 'sort' => 'title'));
$html = sort_options('/article');
$this->assertStringContainsString('&amp;queue=0&amp;search=a+b%26c%22%3C&amp;start=0&amp;show=10"', $html);
$this->assertStringNotContainsString('<script>', $html);
}
public function testFirstPageHasOnlyAPreviousLink(): void {
$html = get_pager();
$this->assertStringContainsString('<span class="next"></span>', $html);
$this->assertStringContainsString('<a href="/article?start=5">Previous five articles &raquo;</a>', $html);
}
public function testMiddlePageHasBothLinks(): void {
$this->request(array('start' => '5'));
$html = get_pager();
$this->assertStringContainsString('<a href="/article?start=0">&laquo; Next five articles</a>', $html);
$this->assertStringContainsString('<a href="/article?start=10">Previous five articles &raquo;</a>', $html);
}
public function testLastPageHasOnlyANextLink(): void {
$this->request(array('start' => '10'));
$html = get_pager();
$this->assertStringContainsString('&laquo; Next five articles', $html);
$this->assertStringContainsString("<span class=\"prev\"></span>", $html);
}
public function testNextNeverGoesBelowZero(): void {
$this->request(array('start' => '2'));
$this->assertStringContainsString('<a href="/article?start=0">', get_pager());
}
public function testOneArticleIsSingular(): void {
$this->request(array('show' => '1'));
$this->assertStringContainsString('Previous article &raquo;', get_pager());
}
public function testPageSizeInWords(): void {
$this->request(array('show' => '3', 'start' => '3'));
$html = get_pager();
$this->assertStringContainsString('&laquo; Next three articles', $html);
$this->assertStringContainsString('Previous three articles &raquo;', $html);
}
public function testPagerKeepsTheOtherArgumentsSafely(): void {
$this->request(array('start' => '5', 'show' => '5', 'search' => 'Story "1', 'queue' => 'x', 'sort' => 'bad', 'order' => 'bad'));
$html = get_pager();
$this->assertStringContainsString('href="/article?start=0&amp;queue=0&amp;search=Story+%221&amp;show=5&amp;sort=date&amp;order=desc"', $html);
}
public function testEditablePagerStaysOnThePage(): void {
$GLOBALS['editable'] = TRUE;
$this->assertStringContainsString('<a href="?start=5">', get_pager());
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Xxx\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use Xxx\Tests\Support\TestCase;
// number_words() in navigation.lib.php
class NumberWordsTest extends TestCase {
public static function numbers(): array {
return array(
array(0, 'zero'),
array(1, 'one'),
array(5, 'five'),
array(10, 'ten'),
array(13, 'thirteen'),
array(19, 'nineteen'),
array(20, 'twenty'),
array(21, 'twenty-one'),
array(25, 'twenty-five'),
array(50, 'fifty'),
array(99, 'ninety-nine'),
array(100, 'one hundred'),
array(101, 'one hundred and one'),
array(105, 'one hundred and five'),
array(110, 'one hundred and ten'),
array(999, 'nine hundred and ninety-nine'),
array(1000, 'one thousand'),
array(1001, 'one thousand and one'),
array(1099, 'one thousand and ninety-nine'),
array(1100, 'one thousand one hundred'),
array(12345, 'twelve thousand three hundred and forty-five'),
array(100000, 'one hundred thousand'),
array(1000000, 'one million'),
array(2000300, 'two million three hundred'),
array(1000000001, 'one billion and one'),
array(-7, 'minus seven'),
array(-121, 'minus one hundred and twenty-one'),
);
}
#[DataProvider('numbers')]
public function testWords(int $number, string $words): void {
$this->assertSame($words, number_words($number));
}
public function testNumericStringsAreAccepted(): void {
$this->assertSame('forty-two', number_words('42'));
}
// The pager shows between 1 and MAX_SHOW stories
public function testEveryPageSizeHasWords(): void {
for ($n = 1; $n <= MAX_SHOW; $n++)
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)?$/', number_words($n), "for $n");
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\TestCase;
// open_db() and db_fail() in open_db.lib.php
class OpenDbTest extends TestCase {
public function testOpenConnectsWithTheConfiguredSettings(): void {
$this->assertTrue(open_db('open'));
$this->assertInstanceOf(FakeDb::class, $GLOBALS['db']);
$this->assertSame(array(array('db.test', 'test-user', 'test-pass', 'test-db')), FakeDb::$connections);
}
public function testOpenTwiceKeepsTheFirstConnection(): void {
open_db('open');
$first = $GLOBALS['db'];
$this->assertFalse(open_db('open'));
$this->assertSame($first, $GLOBALS['db']);
$this->assertCount(1, FakeDb::$connections);
}
public function testCloseClosesAndForgetsTheConnection(): void {
open_db('open');
$connection = $GLOBALS['db'];
$this->assertTrue(open_db('close'));
$this->assertTrue($connection->closed);
$this->assertNull($GLOBALS['db']);
$this->assertFalse(open_db('close'));
}
public function testOpenAfterCloseConnectsAgain(): void {
open_db('open');
open_db('close');
open_db('open');
$this->assertCount(2, FakeDb::$connections);
}
public function testDbHostDefaultsToLocalhost(): void {
$root = var_export(XXX_ROOT, TRUE);
$result = PhpProcess::runPlain(
"require $root . '/vendor/autoload.php';\n" .
"if (!function_exists('mysqli_report')) { define('MYSQLI_REPORT_OFF', 0); function mysqli_report(\$f) { return TRUE; } }\n" .
"require $root . '/open_db.lib.php';\n" .
"\$db_class = Xxx\\Tests\\Support\\FakeDb::class;\n" .
"open_db('open');\n" .
"echo json_encode(Xxx\\Tests\\Support\\FakeDb::\$connections);",
array('XXX_ENV_FILE' => $this->tmp . '/none'));
$this->assertSame('[["localhost",null,null,null]]', $result['stdout'], $result['stderr']);
}
public function testConnectErrorStopsThePageAndLogsTheDetails(): void {
$result = PhpProcess::run('Xxx\Tests\Support\FakeDb::$connectError = "Access denied for user"; open_db("open"); echo "not reached";');
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $result['stdout']);
$this->assertStringContainsString('xxx: database error at open_db.lib.php:', $result['log']);
$this->assertStringContainsString('connect error (1045) Access denied for user', $result['log']);
$this->assertStringNotContainsString('Access denied', $result['stdout']);
}
public function testDbFailLogsTheCallerAndTheDatabaseError(): void {
$result = PhpProcess::run("open_db('open');\n\$GLOBALS['db']->error = \"Table 'secret' doesn't exist\";\ndb_fail();\necho 'not reached';");
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $result['stdout']);
$this->assertMatchesRegularExpression("/xxx: database error at xxx-php-\\w+:5: Table 'secret' doesn't exist/", $result['log']);
}
public function testDbFailWithoutAConnection(): void {
$result = PhpProcess::run('db_fail();');
$this->assertStringContainsString('no database connection', $result['log']);
}
public function testDbFailWithDetail(): void {
$result = PhpProcess::run('db_fail("custom detail");');
$this->assertStringContainsString(': custom detail', $result['log']);
}
public function testFailedQueryInTheCodeStopsThePage(): void {
$result = PhpProcess::run('Xxx\Tests\Support\FakeDb::on("/COUNT/", FALSE); open_db("open"); get_count(); echo "not reached";');
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $result['stdout']);
$this->assertStringContainsString('database error at get_data.lib.php:', $result['log']);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// Password hashing and upgrades in session.lib.php
class PasswordTest extends TestCase {
protected function setUp(): void {
parent::setUp();
open_db('open');
}
private function addEditor(string $hash): void {
SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => $hash, 'email' => 'ed@example.org');
}
private function storedHash(): string {
return SiteData::$tables['editors'][0]['password'];
}
public function testAlgorithmIsArgon2idWhenAvailable(): void {
$this->assertSame(defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT, password_algorithm());
}
public function testHashIsStoredAndChecked(): void {
$this->addEditor('old');
$hash = password_hash('secret', password_algorithm());
$this->assertTrue(store_password_hash('ed', $hash));
$this->assertSame($hash, $this->storedHash());
$this->assertTrue(password_hash_is_stored('ed', $hash));
$this->assertFalse(password_hash_is_stored('ed', 'other'));
$this->assertFalse(password_hash_is_stored('nobody', $hash));
}
public function testCutHashIsNotStored(): void {
$this->addEditor('old');
SiteData::$passwordLength = 60;
$hash = str_repeat('h', 97);
$this->assertFalse(store_password_hash('ed', $hash));
}
public function testOldMd5CryptHashIsUpgraded(): void {
$old = crypt('secret', '$1$abcdefgh$');
$this->assertTrue(password_verify('secret', $old), 'password_verify() still accepts the old hashes');
$this->addEditor($old);
rehash_password('ed', 'secret', $old);
$new = $this->storedHash();
$this->assertNotSame($old, $new);
$this->assertTrue(password_verify('secret', $new));
$this->assertFalse(password_needs_rehash($new, password_algorithm()));
}
public function testCurrentHashIsLeftAlone(): void {
$hash = password_hash('secret', password_algorithm());
$this->addEditor($hash);
rehash_password('ed', 'secret', $hash);
$this->assertSame($hash, $this->storedHash());
$this->assertSame('', $this->loggedErrors());
}
public function testShortColumnKeepsTheOldHash(): void {
$old = crypt('secret', '$1$abcdefgh$');
$this->addEditor($old);
SiteData::$passwordLength = 50;
rehash_password('ed', 'secret', $old);
$this->assertSame($old, $this->storedHash());
$this->assertStringContainsString('could not store a new password hash for ed; widen editors.password', $this->loggedErrors());
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// get_count() and get_query() in get_data.lib.php
class QueryTest extends TestCase {
protected function setUp(): void {
parent::setUp();
SiteData::install(array(
'xxx' => array(
array('id' => 1, 'title' => 'Apple', 'author' => 'Zed', 'date' => '2020-01-01 10:00:00'),
array('id' => 2, 'title' => 'Banana', 'author' => 'Yan', 'date' => '2020-02-01 10:00:00', 'story' => '<p>A cherry story.</p>'),
array('id' => 3, 'title' => 'Cherry', 'author' => 'Xi', 'date' => '2020-03-01 10:00:00', 'hidden' => '1'),
),
'queue' => array(
array('id' => 10, 'title' => 'Queued', 'date' => '2020-04-01 10:00:00'),
),
));
open_db('open');
}
private function lastQuery(): array {
return end(FakeDb::$log);
}
public function testVisitorsDoNotCountHiddenStories(): void {
$this->assertSame('2', get_count());
$this->assertSame('SELECT COUNT(*) FROM xxx WHERE 1 AND hidden IS NOT TRUE', $this->lastQuery()['sql']);
}
public function testEditorsCountHiddenStories(): void {
$this->asEditor();
$this->assertSame('3', get_count());
$this->assertSame('SELECT COUNT(*) FROM xxx WHERE 1', $this->lastQuery()['sql']);
}
public function testEditorsCountTheQueue(): void {
$this->asEditor();
$this->request(array('queue' => '1'));
$this->assertSame('1', get_count());
}
public function testVisitorsCannotCountTheQueue(): void {
$this->request(array('queue' => '1'));
$this->assertSame('2', get_count());
$this->assertStringContainsString('FROM xxx', $this->lastQuery()['sql']);
}
public function testSearchCountBindsTheTermFourTimes(): void {
$this->request(array('search' => "cherry' OR '1'='1"));
get_count();
$query = $this->lastQuery();
$this->assertSame("SELECT COUNT(*) FROM xxx WHERE (title LIKE ? OR author LIKE ? OR date LIKE ? OR story LIKE ?) AND hidden IS NOT TRUE", $query['sql']);
$this->assertSame(array_fill(0, 4, "%cherry' OR '1'='1%"), $query['params']);
}
public function testSearchCountFindsMatches(): void {
$this->request(array('search' => 'cherry'));
$this->assertSame('1', get_count()); // Banana's story; Cherry is hidden
}
public function testDefaultListIsNewestFirstWithoutHiddenStories(): void {
$GLOBALS['num_stories'] = 5;
$rows = get_query();
$this->assertSame(array('2', '1'), array_column($rows, 'id'));
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE ORDER BY date desc LIMIT 0 , 5', $this->lastQuery()['sql']);
$this->assertSame(array(0, 0), array_column($rows, 'queue_url'));
}
public function testEditorsSeeHiddenStories(): void {
$this->asEditor();
$GLOBALS['num_stories'] = 5;
$this->assertSame(array('3', '2', '1'), array_column(get_query(), 'id'));
}
public function testSortOrderStartAndShow(): void {
$this->request(array('sort' => 'title', 'order' => 'asc', 'start' => '1', 'show' => '1'));
$rows = get_query();
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE ORDER BY title asc LIMIT 1 , 1', $this->lastQuery()['sql']);
$this->assertSame(array('Banana'), array_column($rows, 'title'));
}
public function testBadSortAndOrderValuesFallBack(): void {
$this->request(array('sort' => 'id; DROP TABLE xxx', 'order' => 'desc; --', 'start' => 'x', 'show' => 'y'));
get_query();
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE ORDER BY date desc LIMIT 0 , 1', $this->lastQuery()['sql']);
}
public function testOneStoryById(): void {
$rows = get_query('2');
$this->assertSame('SELECT * FROM xxx WHERE 1 AND hidden IS NOT TRUE AND id = 2 LIMIT 0, 1', $this->lastQuery()['sql']);
$this->assertSame(array('Banana'), array_column($rows, 'title'));
}
public function testVisitorsCannotOpenAHiddenStory(): void {
$this->assertSame(array(), get_query('3'));
}
public function testEditorsCanOpenAHiddenStory(): void {
$this->asEditor();
$this->assertSame(array('Cherry'), array_column(get_query('3'), 'title'));
$this->assertSame('SELECT * FROM xxx WHERE 1 AND id = 3 LIMIT 0, 1', $this->lastQuery()['sql']);
}
public function testIdIsCastToAnInteger(): void {
get_query('2.9');
$this->assertStringContainsString('AND id = 2 LIMIT', $this->lastQuery()['sql']);
}
public function testNonNumericIdListsStories(): void {
get_query('2 OR 1=1');
$this->assertStringNotContainsString('OR 1=1', $this->lastQuery()['sql']);
$this->assertStringContainsString('ORDER BY', $this->lastQuery()['sql']);
}
public function testSearchListBindsTheTerm(): void {
$this->request(array('search' => '%_'));
get_query();
$query = $this->lastQuery();
$this->assertStringStartsWith('SELECT * FROM xxx WHERE (title LIKE ? OR author LIKE ? OR date LIKE ? OR story LIKE ?) AND hidden IS NOT TRUE ORDER BY', $query['sql']);
$this->assertSame(array_fill(0, 4, '%%_%'), $query['params']);
}
public function testQueueRowsAreMarked(): void {
$this->asEditor();
$this->request(array('queue' => '1'));
$GLOBALS['num_stories'] = 5;
$rows = get_query();
$this->assertSame(array('10'), array_column($rows, 'id'));
$this->assertSame(array(1), array_column($rows, 'queue_url'));
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
namespace Xxx\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use Xxx\Tests\Support\TestCase;
// The functions in get_data.lib.php that read and clean request values
class RequestArgsTest extends TestCase {
protected function setUp(): void {
parent::setUp();
open_db('open');
}
public static function intArgs(): array {
return array(
'whole number' => array('7', 7),
'spaces around' => array(' 7 ', 7),
'negative' => array('-3', 0),
'above the maximum' => array('500', 100),
'zero' => array('0', 0),
'decimal' => array('7.5', NULL),
'trailing text' => array('7abc', NULL),
'leading zero' => array('07', NULL),
'plus sign' => array('+7', 7),
'exponent' => array('1e3', NULL),
'SQL' => array('1; DROP TABLE xxx', NULL),
'empty' => array('', NULL),
);
}
#[DataProvider('intArgs')]
public function testGetIntArg(string $value, ?int $expected): void {
$this->request(array('n' => $value));
$this->assertSame($expected, get_int_arg('n', 0, 100));
}
public function testGetIntArgMissingOrNotAString(): void {
$this->assertNull(get_int_arg('n', 0));
$this->request(array('n' => array('1')));
$this->assertNull(get_int_arg('n', 0));
}
public function testGetIntArgHasNoMaximumByDefault(): void {
$this->request(array('n' => '123456789'));
$this->assertSame(123456789, get_int_arg('n', 0));
}
public function testShowNumComesFromTheRequest(): void {
$this->request(array('show' => '12'));
$this->assertSame(12, get_show_num());
}
public function testShowNumIsCappedAndAtLeastOne(): void {
$this->request(array('show' => '100000'));
$this->assertSame(MAX_SHOW, get_show_num());
$this->request(array('show' => '0'));
$this->assertSame(1, get_show_num());
}
public function testShowNumFallsBackToThePageSetting(): void {
$GLOBALS['num_stories'] = 5;
$this->assertSame(5, get_show_num());
$this->request(array('show' => 'many'));
$this->assertSame(5, get_show_num());
}
public function testShowNumFallsBackToOne(): void {
$this->assertSame(1, get_show_num());
}
public function testStartNum(): void {
$this->assertSame(0, get_start_num());
$this->request(array('start' => '15'));
$this->assertSame(15, get_start_num());
$this->request(array('start' => '-15'));
$this->assertSame(0, get_start_num());
$this->request(array('start' => '15 OR 1=1'));
$this->assertSame(0, get_start_num());
}
public static function sorts(): array {
return array(
array(NULL, 'date'), array('author', 'author'), array('title', 'title'), array('date', 'date'),
array('id', 'date'), array('title; DROP TABLE xxx', 'date'), array('TITLE', 'date'),
);
}
#[DataProvider('sorts')]
public function testSortArgAllowsOnlyKnownColumns(?string $sort, string $expected): void {
if ($sort !== NULL)
$this->request(array('sort' => $sort));
$this->assertSame($expected, get_sort_arg());
}
public function testSortArgIgnoresArrays(): void {
$this->request(array('sort' => array('title')));
$this->assertSame('date', get_sort_arg());
}
public static function orders(): array {
return array(
'date default' => array(array(), 'desc'),
'title default' => array(array('sort' => 'title'), 'asc'),
'author default' => array(array('sort' => 'author'), 'asc'),
'asc' => array(array('order' => 'asc'), 'asc'),
'DESC' => array(array('order' => 'DESC', 'sort' => 'title'), 'DESC'),
'injection' => array(array('order' => 'desc, (SELECT 1)'), 'desc'),
'unknown' => array(array('order' => 'sideways', 'sort' => 'title'), 'asc'),
'array' => array(array('order' => array('asc')), 'desc'),
);
}
#[DataProvider('orders')]
public function testOrderAllowsOnlyAscAndDesc(array $get, string $expected): void {
$this->request($get);
$this->assertSame($expected, get_order());
}
public function testSearchArgs(): void {
$this->assertNull(get_search_args());
$this->request(array('search' => "O'Brien <b>"));
$this->assertSame("O'Brien <b>", get_search_args());
$this->request(array('search' => array('x')));
$this->assertNull(get_search_args());
}
public function testIdMustBeNumeric(): void {
$this->assertNull(get_id());
$this->request(array('id' => 'abc'));
$this->assertNull(get_id());
$this->request(array('id' => '1 OR 1=1'));
$this->assertNull(get_id());
$this->request(array('id' => '42'));
$this->assertSame('42', get_id());
}
public function testIdComesFromPostToo(): void {
$this->request(array(), array('id' => '9'));
$this->assertSame('9', get_id());
}
public function testIdKeepsAGlobalValue(): void {
$GLOBALS['id'] = 5;
$this->request(array('id' => '42'));
$this->assertSame(5, get_id());
}
public function testIdIgnoresArrays(): void {
$this->request(array('id' => array('1')));
$this->assertNull(get_id());
}
public function testQueueIsOffByDefault(): void {
$this->assertFalse(get_queue());
}
public function testQueueComesFromTheRequest(): void {
$this->request(array('queue' => '1'));
$this->assertTrue(get_queue());
$GLOBALS['queue'] = NULL;
$this->request(array('queue' => '0'));
$this->assertFalse(get_queue());
$GLOBALS['queue'] = NULL;
$this->request(array('queue' => 'yes'));
$this->assertFalse(get_queue());
$GLOBALS['queue'] = NULL;
$this->request(array('queue' => array('1')));
$this->assertFalse(get_queue());
}
public function testQueueIsOnForSaveAndSubmit(): void {
foreach (array('save', 'submit') as $action) {
$GLOBALS['action'] = $action;
$this->request(array('queue' => '0'));
$this->assertTrue(get_queue(), $action);
}
}
public function testQueueKeepsAGlobalValue(): void {
$GLOBALS['queue'] = TRUE;
$this->assertTrue(get_queue());
}
public static function tables(): array {
// is_admin, queue, move, action => table
return array(
'visitor reads the live table' => array(FALSE, FALSE, FALSE, 'read', 'xxx'),
'visitor cannot read the queue' => array(FALSE, TRUE, FALSE, 'read', 'xxx'),
'editor reads the queue' => array(TRUE, TRUE, FALSE, 'read', 'queue'),
'editor reads the live table' => array(TRUE, FALSE, FALSE, 'read', 'xxx'),
'visitor post goes to the queue' => array(FALSE, FALSE, FALSE, 'post', 'queue'),
'visitor save goes to the queue' => array(FALSE, FALSE, FALSE, 'save', 'queue'),
'visitor submit goes to the queue' => array(FALSE, FALSE, FALSE, 'submit', 'queue'),
'visitor delete only touches the queue' => array(FALSE, FALSE, TRUE, 'delete', 'queue'),
'editor post goes live' => array(TRUE, TRUE, TRUE, 'post', 'xxx'),
'editor save goes to the queue' => array(TRUE, FALSE, FALSE, 'save', 'queue'),
'editor submit goes to the queue' => array(TRUE, FALSE, FALSE, 'submit', 'queue'),
'editor moving deletes from the queue' => array(TRUE, TRUE, TRUE, 'delete', 'queue'),
'editor deletes from the live table' => array(TRUE, FALSE, FALSE, 'delete', 'xxx'),
'editor deletes from the queue' => array(TRUE, TRUE, FALSE, 'delete', 'queue'),
);
}
#[DataProvider('tables')]
public function testWhichTable(bool $isAdmin, bool $queue, bool $move, string $action, string $table): void {
$GLOBALS['is_admin'] = $isAdmin;
$GLOBALS['queue'] = $queue;
$this->assertSame($table, which_table($move, $action));
}
public function testPostDataHasEveryField(): void {
$this->request(array(), array('title' => 'T', 'author' => 'A', 'email' => 'e@x.org', 'story' => '<p>S</p>',
'comments' => 'C', 'date' => '2020-01-01', 'hidden' => 'on', 'uploaded' => 'pending',
'editor' => 'ed', 'remove_img' => '1', 'id' => '4', 'ignored' => 'x'));
$entry = get_post_data()[0];
$this->assertSame(array('id', 'title', 'author', 'email', 'upload', 'story', 'comments', 'date', 'hidden',
'uploaded', 'editor', 'remove_img', 'queue_url'), array_keys($entry));
$this->assertSame('T', $entry['title']);
$this->assertSame(1, $entry['remove_img']);
$this->assertNull($entry['upload']);
$this->assertSame(0, $entry['queue_url']);
}
public function testPostDataAsStrings(): void {
$this->request(array(), array('title' => array('not', 'a string'), 'remove_img' => '0', 'id' => '4'));
$entry = get_post_data(TRUE)[0];
$this->assertSame('', $entry['title']);
$this->assertSame('', $entry['author']);
$this->assertSame('0', $entry['remove_img']);
$this->assertSame('4', $entry['id']);
foreach ($entry as $value)
$this->assertIsString($value);
}
public function testPostDataEscapedForHtml(): void {
$this->request(array(), array('title' => '<b>"T" & co</b>', 'story' => 'Wait&hellip;'));
$entry = get_post_data(TRUE, TRUE)[0];
$this->assertSame('&lt;b&gt;&quot;T&quot; &amp; co&lt;/b&gt;', $entry['title']);
$this->assertSame('Wait&hellip;', $entry['story']);
}
public function testPostDataQueueUrlFollowsTheQueue(): void {
$GLOBALS['queue'] = TRUE;
$this->assertSame(1, get_post_data()[0]['queue_url']);
}
public function testMapHelpers(): void {
$this->assertSame('', map_string(NULL));
$this->assertSame('', map_string(array(1)));
$this->assertSame('0', map_string(0));
$this->assertSame('1', map_string(TRUE));
$this->assertSame('&lt;a href=&quot;x&quot;&gt;', map_html('<a href="x">'));
$this->assertSame('<a href="x">', unmap_html(map_html('<a href="x">')));
$this->assertSame("it's", unmap_html('it&#039;s'));
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// purify_story() in lib/purify.inc.php and sanitise() in publish/post.lib.php
class SanitiseTest extends TestCase {
public function testPurifyKeepsTheEditorMarkup(): void {
$html = '<p style="text-align: center;">A <strong>b</strong> <em>c</em> <u>d</u> <sup>2</sup><sub>3</sub></p>'
. '<blockquote style="margin-left: 40px;"><p>q</p></blockquote>'
. '<p style="padding-left: 40px;">in</p><ul><li>one</li></ul><ol><li>two</li></ol>'
. '<p><a href="https://example.org/" title="t">link</a> <a href="mailto:a@example.org">mail</a><br /></p><hr />';
// HTML Purifier writes the styles without spaces
$this->assertSame(str_replace(': ', ':', $html), purify_story($html));
}
public function testPurifyRemovesScriptsAndHandlers(): void {
$this->assertSame('<p>hi</p>', purify_story('<p onclick="x()">hi<script>alert(1)</script></p>'));
$this->assertSame('<p>x</p>', purify_story('<p>x<img src="a" onerror="alert(1)" /></p>'));
$this->assertSame('', purify_story('<iframe src="https://example.org/"></iframe><style>p{}</style>'));
}
public function testPurifyRemovesUnsafeLinks(): void {
$this->assertSame('<a>x</a>', purify_story('<a href="javascript:alert(1)">x</a>'));
$this->assertSame('<a>x</a>', purify_story('<a href="data:text/html,hi">x</a>'));
$this->assertSame('<a href="https://example.org/">x</a>', purify_story('<a href="https://example.org/" target="_blank" onclick="y()" id="z">x</a>'));
}
public function testPurifyKeepsOnlySomeStyles(): void {
$this->assertSame('<p style="text-align:right;">x</p>', purify_story('<p style="text-align: right; color: red; position: fixed">x</p>'));
$this->assertSame('<span>x</span>', purify_story('<span style="background: url(javascript:alert(1))">x</span>'));
}
public function testPurifyClosesOpenTags(): void {
$this->assertSame('<p><strong>x</strong></p>', purify_story('<p><strong>x'));
$this->assertSame('', purify_story(NULL));
}
public function testSanitiseLeavesAnEmptyStory(): void {
$this->assertSame('', sanitise(''));
$this->assertNull(sanitise(NULL));
}
// SmartyPants writes numeric entities, and HTML Purifier turns them into characters
public function testNumberRangesGetEnDashesInTextOnly(): void {
$this->assertSame('<p>pages 10–20 and 3–4</p>', sanitise('<p>pages 10-20 and 3--4</p>'));
$this->assertSame('<p><a href="https://example.org/1-2">1–2</a></p>', sanitise('<p><a href="https://example.org/1-2">1-2</a></p>'));
}
public function testDoubleHyphensBecomeEmDashes(): void {
$this->assertSame('<p>a — b and c—d</p>', sanitise('<p>a -- b and c--d</p>'));
}
public function testThreeDotsBecomeAnEllipsis(): void {
$this->assertSame('<p>a.b.c and 1..2</p>', sanitise('<p>a.b.c and 1..2</p>'));
$this->assertSame('<p>Wait… what… now</p>', sanitise('<p>Wait... what. . . now</p>'));
}
public function testQuotesBecomeCurly(): void {
$this->assertSame('<p>“Hi,” she said. It’s ‘fine’.</p>',
sanitise('<p>"Hi," she said. It\'s \'fine\'.</p>'));
}
public function testScriptsAndCommentsAreRemoved(): void {
$this->assertSame('<p>ab</p>', sanitise("<p>a<script type=\"text/javascript\">\nalert('x');\n</script>b</p>"));
$this->assertSame('<p>ab</p>', sanitise("<p>a<!-- a\ncomment -->b</p>"));
}
public function testTheResultIsPurified(): void {
$this->assertSame('<p>x</p>', sanitise('<p onmouseover="steal()">x</p>'));
$this->assertSame('<a>x</a>', sanitise('<a href="javascript:steal()">x</a>'));
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\Mail;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// save_data() in publish/post.lib.php
class SaveDataTest extends TestCase {
private const TABLES = array(
'xxx' => array(array('id' => 1, 'title' => 'Live', 'date' => '2020-01-01 10:00:00', 'story' => '<p>Old.</p>')),
'queue' => array(array('id' => 5, 'title' => 'Waiting', 'date' => '2020-03-01 10:00:00', 'story' => '',
'img' => 'QUEUED IMAGE', 'mime' => 'image/gif')),
);
protected function setUp(): void {
parent::setUp();
SiteData::install(self::TABLES);
open_db('open');
}
// Save as an editor, with $action and the POST fields given
private function save(string $action, array $post, array $get = array()): string {
$this->asEditor();
$GLOBALS['action'] = $action;
$this->request($get, $post + array('title' => 'Title', 'author' => 'Ann', 'story' => '<p>Text.</p>'));
return (string)save_data();
}
private function pendingImage(): string {
$name = bin2hex(random_bytes(16));
file_put_contents(upload_dir() . "/$name", self::png());
$_SESSION['pending_upload'] = $name;
return upload_dir() . "/$name";
}
public function testEditorSavesANewStoryToTheQueue(): void {
$id = $this->save('save', array('email' => 'ann@example.org', 'comments' => 'Note', 'editor' => 'ed',
'hidden' => '1', 'date' => '2021-05-06 07:08:09'));
$this->assertSame('6', $id);
$row = SiteData::find('queue', 6);
$this->assertSame(array('Title', 'Ann', 'ann@example.org', '<p>Text.</p>', 'Note', 'ed', 1, '2021-05-06 07:08:09'),
array($row['title'], $row['author'], $row['email'], $row['story'], $row['comments'], $row['editor'], $row['hidden'], $row['date']));
$this->assertCount(1, SiteData::$tables['xxx']);
}
public function testANewStoryGetsTheDateNow(): void {
$this->save('save', array());
$date = SiteData::find('queue', 6)['date'];
$this->assertEqualsWithDelta(time(), strtotime($date . ' UTC'), 5);
}
public function testEditorUpdatesAQueuedStoryInPlace(): void {
$id = $this->save('save', array('id' => '5', 'title' => 'Changed', 'date' => '2030-01-01 00:00:00'));
$this->assertSame('5', $id);
$this->assertSame('Changed', SiteData::find('queue', 5)['title']);
// An update keeps the date
$this->assertSame('2020-03-01 10:00:00', SiteData::find('queue', 5)['date']);
$update = FakeDb::matching('/^UPDATE queue/')[0];
$this->assertStringEndsWith('WHERE `id` = ? LIMIT 1', $update['sql']);
$this->assertSame(5, end($update['params']));
}
public function testEditorUpdatesALiveStory(): void {
$this->assertSame('1', $this->save('post', array('id' => '1', 'title' => 'Edited')));
$this->assertSame('Edited', SiteData::find('xxx', 1)['title']);
$this->assertCount(1, SiteData::$tables['xxx']);
}
public function testPostingFromTheQueueMovesTheStory(): void {
$id = $this->save('post', array('id' => '5', 'title' => 'Now live', 'date' => '2021-01-01 00:00:00'), array('queue' => '1'));
$this->assertSame('6', $id);
$row = SiteData::find('xxx', 6);
$this->assertSame(array('Now live', '2021-01-01 00:00:00', '<p>Text.</p>', NULL), array($row['title'], $row['date'], $row['story'], $row['img']));
$this->assertNull(SiteData::find('queue', 5));
$this->assertFalse($GLOBALS['queue']);
}
public function testPostingFromTheQueueCanKeepTheQueuedImage(): void {
$this->save('post', array('id' => '5', 'uploaded' => '5'), array('queue' => '1'));
$row = SiteData::find('xxx', 6);
$this->assertSame(array('QUEUED IMAGE', 'image/gif', ''), array($row['img'], $row['mime'], $row['story']));
}
public function testAPendingUploadBecomesTheImage(): void {
$file = $this->pendingImage();
$this->save('save', array('uploaded' => 'pending'));
$row = SiteData::find('queue', 6);
$this->assertSame(array(self::png(), 'image/png', ''), array($row['img'], $row['mime'], $row['story']));
$this->assertFileDoesNotExist($file);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testRemovingTheImageKeepsTheText(): void {
$this->save('save', array('id' => '5', 'remove_img' => '1', 'story' => '<p>Words.</p>'));
$row = SiteData::find('queue', 5);
$this->assertSame(array(NULL, NULL, '<p>Words.</p>'), array($row['img'], $row['mime'], $row['story']));
}
public function testTheStoryIsSanitised(): void {
$this->save('save', array('story' => '<p onclick="x()">a -- b<script>alert(1)</script></p>'));
$this->assertSame('<p>a — b</p>', SiteData::find('queue', 6)['story']);
}
public function testValuesAreBoundAsParameters(): void {
$this->save('save', array('title' => "O'Brien\"; DROP TABLE xxx; --"));
$insert = FakeDb::matching('/^INSERT INTO queue/')[0];
$this->assertStringNotContainsString('DROP', $insert['sql']);
$this->assertContains("O'Brien\"; DROP TABLE xxx; --", $insert['params']);
}
// Visitors always add to the queue, and each story sends mail
public function testVisitorsCanOnlyAddToTheQueue(): void {
$state = $this->tmp . '/state';
file_put_contents($state, serialize(array(
'tables' => self::TABLES,
'post' => array('id' => '1', 'title' => 'Mine', 'author' => 'Vi', 'story' => '<p>Hi.</p>', 'comments' => 'sneaky',
'editor' => 'ed', 'hidden' => '1', 'date' => '1999-01-01 00:00:00'),
'session' => array('captcha_ok' => time()),
)));
$file = var_export($state, TRUE);
$code = <<<PHP
\$state = unserialize(file_get_contents($file));
Xxx\Tests\Support\SiteData::install(\$state['tables']);
open_db('open');
\$action = 'post';
\$_POST = \$_REQUEST = \$state['post'];
\$_SESSION = \$state['session'];
\$id = save_data();
file_put_contents($file, serialize(array('id' => \$id, 'tables' => Xxx\Tests\Support\SiteData::\$tables, 'session' => \$_SESSION, 'action' => \$action)));
PHP;
$result = Mail::run($code, array('UPLOAD_DIR' => $this->tmp . '/uploads'));
$this->assertSame(0, $result['status'], $result['stderr']);
$this->assertSame('', $result['log']);
$saved = unserialize(file_get_contents($state));
$this->assertSame('6', (string)$saved['id']);
$this->assertSame('submit', $saved['action']);
$this->assertSame(self::TABLES['xxx'][0]['title'], $saved['tables']['xxx'][0]['title']);
$row = end($saved['tables']['queue']);
$this->assertSame(array('6', 'Mine', 'original author', 0, '', '<p>Hi.</p>'),
array($row['id'], $row['title'], $row['editor'], $row['hidden'], $row['comments'], $row['story']));
$this->assertNotSame('1999-01-01 00:00:00', $row['date']);
$this->assertSame(array('allow_queue_image' => '6'), $saved['session']);
$this->assertCount(1, $result['mails']);
$this->assertSame('New submission: Mine', $result['mails'][0]['subject']);
$this->assertStringContainsString('Vi has sent the following new submission', $result['mails'][0]['body']);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// CSRF tokens, editor logins and persistent logins in session.lib.php
class SessionTest extends TestCase {
protected function setUp(): void {
parent::setUp();
ini_set('session.save_path', $this->tmp);
ini_set('session.use_cookies', '0');
ini_set('session.cache_limiter', '');
}
protected function tearDown(): void {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
parent::tearDown();
}
public function testCsrfTokenIsRandomAndKept(): void {
$token = csrf_token();
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token);
$this->assertSame($token, csrf_token());
$this->assertSame($token, $_SESSION['csrf_token']);
$_SESSION = array();
$this->assertNotSame($token, csrf_token());
}
public function testCsrfTokenReplacesABadSessionValue(): void {
$_SESSION['csrf_token'] = array('not a string');
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', csrf_token());
}
public function testCsrfField(): void {
$this->assertSame('<input type="hidden" name="csrf_token" value="' . csrf_token() . '" />', csrf_field());
}
public function testCsrfValidAcceptsOnlyTheSessionToken(): void {
$token = csrf_token();
$this->assertTrue(csrf_valid($token));
$this->assertFalse(csrf_valid(strtoupper($token)));
$this->assertFalse(csrf_valid(''));
$this->assertFalse(csrf_valid($token . 'x'));
$this->assertFalse(csrf_valid(array($token)));
}
public function testCsrfValidReadsThePostedToken(): void {
$token = csrf_token();
$this->assertFalse(csrf_valid());
$this->request(array(), array('csrf_token' => $token));
$this->assertTrue(csrf_valid());
$this->request(array('csrf_token' => $token));
$this->assertFalse(csrf_valid(), 'a token in the query string is not enough');
}
public function testCsrfValidFailsWithoutASessionToken(): void {
$this->assertFalse(csrf_valid(''));
$this->assertFalse(csrf_valid('anything'));
}
public function testLoginEditorGivesANewSessionId(): void {
session_start();
$before = session_id();
$_SESSION['csrf_token'] = 'old token';
$_SESSION['referrer'] = '/about';
login_editor('ed');
$this->assertNotSame($before, session_id());
$this->assertSame('ed', $_SESSION['editor']);
$this->assertArrayNotHasKey('csrf_token', $_SESSION);
$this->assertSame('/about', $_SESSION['referrer']);
$this->assertFileDoesNotExist($this->tmp . '/sess_' . $before);
}
public function testPersistentCookieParts(): void {
$this->assertSame(array('', ''), persistent_cookie());
$_COOKIE['PersistentAuth'] = 'ed|key|with|bars';
$this->assertSame(array('ed', 'key|with|bars'), persistent_cookie());
$_COOKIE['PersistentAuth'] = 'nobar';
$this->assertSame(array('nobar', ''), persistent_cookie());
$_COOKIE['PersistentAuth'] = array('ed', 'key');
$this->assertSame(array('', ''), persistent_cookie());
}
public function testPersistentKeyHash(): void {
$this->assertSame(hash('sha256', 'key'), persistent_key_hash('key'));
}
public function testCreatePersistentLoginStoresOnlyAHash(): void {
$this->assertTrue(create_persistent_login('ed'));
$rows = SiteData::$tables['persistent_sessions'];
$this->assertCount(1, $rows);
$this->assertSame('ed', $rows[0]['username']);
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $rows[0]['session']);
$this->assertNull($GLOBALS['db'], 'the connection is closed again');
}
public function testCheckPersistentLoginWithoutACookie(): void {
$this->assertFalse(check_persistent_login());
$this->assertSame(array(), FakeDb::$log);
}
public function testCheckPersistentLoginWithAnIncompleteCookie(): void {
foreach (array('', 'ed', 'ed|', '|key') as $cookie) {
$_COOKIE['PersistentAuth'] = $cookie;
$this->assertFalse(check_persistent_login(), $cookie);
}
$this->assertSame(array(), FakeDb::$log);
}
public function testCheckPersistentLoginWithAWrongKey(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('right'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|wrong';
$this->assertFalse(check_persistent_login());
$this->assertArrayNotHasKey('editor', $_SESSION);
$query = FakeDb::matching('/FROM persistent_sessions/')[0];
$this->assertSame(array('ed', persistent_key_hash('wrong')), $query['params']);
}
public function testCheckPersistentLoginWithAKeyOfAnotherEditor(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ann', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertFalse(check_persistent_login());
}
public function testCheckPersistentLoginLogsInAndSwapsTheKey(): void {
session_start();
$before = session_id();
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertTrue(check_persistent_login());
$this->assertSame('ed', $_SESSION['editor']);
$this->assertNotSame($before, session_id());
$rows = SiteData::$tables['persistent_sessions'];
$this->assertCount(1, $rows);
$this->assertNotSame(persistent_key_hash('key'), $rows[0]['session'], 'the used key is gone');
}
public function testDeletePersistentLoginRemovesThisKeyAndOldKeys(): void {
SiteData::$tables['persistent_sessions'] = array(
array('username' => 'ed', 'session' => persistent_key_hash('mine'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ed', 'session' => persistent_key_hash('other device'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ann', 'session' => persistent_key_hash('old'), 'created' => '2000-01-01 00:00:00'),
array('username' => 'bo', 'session' => 'an unhashed key from before', 'created' => gmdate('Y-m-d H:i:s')),
);
$_COOKIE['PersistentAuth'] = 'ed|mine';
$this->assertTrue(delete_persistent_login());
$this->assertSame(array(persistent_key_hash('other device')), array_column(SiteData::$tables['persistent_sessions'], 'session'));
$this->assertCount(1, FakeDb::matching('/INTERVAL 1 YEAR.*CHAR_LENGTH\(session\) <> 64/s'));
}
public function testDeletePersistentLoginWithoutACookieStillReaps(): void {
delete_persistent_login();
$this->assertCount(0, FakeDb::matching('/WHERE username = \?/'));
$this->assertCount(1, FakeDb::matching('/CHAR_LENGTH/'));
}
// The top of session.lib.php runs on every page; it needs HTTPS and a session
public function testSessionStartsOnlyOverHttps(): void {
$code = '$_SERVER["HTTPS"] = %s; session_id("abc123"); session_start(); $_SESSION["editor"] = "ed"; session_write_close();'
. ' $_COOKIE[session_name()] = "abc123"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $username ?? NULL, session_status() === PHP_SESSION_ACTIVE));';
$ini = array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => '');
$https = PhpProcess::runBare(sprintf($code, '"on"'), array(), $ini);
$this->assertSame('[true,"ed",true]', $https['stdout'], $https['stderr']);
}
public function testNoSessionOverPlainHttp(): void {
$result = PhpProcess::runBare('include XXX_ROOT . "/session.lib.php"; echo json_encode(array($is_admin, session_status() === PHP_SESSION_ACTIVE));',
array(), array('session.save_path' => $this->tmp));
$this->assertSame('[false,false]', $result['stdout'], $result['stderr']);
}
public function testPersistentCookieLogsInOverHttps(): void {
$store = $this->tmp . '/tables.json';
file_put_contents($store, json_encode(array('persistent_sessions' => array(
array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'))))));
$code = 'Xxx\Tests\Support\SiteData::install(json_decode(file_get_contents(' . var_export($store, TRUE) . '), TRUE));'
. ' $_SERVER["HTTPS"] = "on"; $_COOKIE["PersistentAuth"] = "ed|key"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $username ?? NULL));';
$result = PhpProcess::runBare($code, array(), array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => ''));
$this->assertSame('[true,"ed"]', $result['stdout'], $result['stderr']);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\TestCase;
// The upload directory and the pending upload in get_data.lib.php and publish/post.lib.php
class UploadTest extends TestCase {
private function pending(string $content = ''): string {
$name = bin2hex(random_bytes(16));
$file = upload_dir() . '/' . $name;
file_put_contents($file, $content === '' ? self::png() : $content);
$_SESSION['pending_upload'] = $name;
return $file;
}
public function testUploadDirIsMadeFromTheConfiguration(): void {
$this->assertSame($this->tmp . '/uploads', upload_dir());
$this->assertDirectoryExists($this->tmp . '/uploads');
$this->assertSame(0700, fileperms($this->tmp . '/uploads') & 0777);
}
public function testUploadDirLosesATrailingSlash(): void {
$this->setEnv('UPLOAD_DIR', $this->tmp . '/other/');
$this->assertSame($this->tmp . '/other', upload_dir());
}
public function testUploadDirDefaultsToTheTempDirectory(): void {
$this->setEnv('UPLOAD_DIR', '');
$this->assertSame(sys_get_temp_dir() . '/xxx-uploads', upload_dir());
}
public function testUploadNamesAre32HexDigits(): void {
$this->assertTrue((bool)is_upload_name(str_repeat('a0', 16)));
$this->assertFalse((bool)is_upload_name(str_repeat('a0', 15)));
$this->assertFalse((bool)is_upload_name(str_repeat('A0', 16)));
$this->assertFalse((bool)is_upload_name('../' . str_repeat('a', 29)));
$this->assertFalse((bool)is_upload_name(str_repeat('a', 32) . "\n"));
$this->assertFalse((bool)is_upload_name(array()));
$this->assertFalse((bool)is_upload_name(NULL));
}
public function testStoreUploadRefusesAFileThatWasNotUploaded(): void {
$file = $this->tmp . '/local';
file_put_contents($file, 'x');
$this->assertFalse(store_upload($file));
$this->assertFileExists($file);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testPendingUploadFindsTheSessionFile(): void {
$file = $this->pending();
$this->assertSame($file, pending_upload());
}
public function testPendingUploadNeedsAValidNameAndAFile(): void {
$this->assertFalse(pending_upload());
$_SESSION['pending_upload'] = '../../etc/passwd';
$this->assertFalse(pending_upload());
$_SESSION['pending_upload'] = str_repeat('b', 32);
$this->assertFalse(pending_upload());
}
public function testPendingUploadUrlHasTheStartOfTheName(): void {
$_SESSION['pending_upload'] = '0123456789abcdef0123456789abcdef';
$this->assertSame('/display?pending=01234567', pending_upload_url());
unset($_SESSION['pending_upload']);
$this->assertSame('/display?pending=', pending_upload_url());
}
public function testMimeTypeComesFromTheImage(): void {
$this->assertSame('image/png', get_mimetype($this->pending()));
$text = $this->tmp . '/text';
file_put_contents($text, 'This is not an image, but it is long enough to read.');
$this->assertNull(get_mimetype($text));
}
public function testSaveUploadedFileUsesThePendingUpload(): void {
$file = $this->pending();
$this->assertFalse(save_uploaded_file());
$_POST['uploaded'] = 'pending';
$this->assertSame($file, save_uploaded_file());
}
public function testSaveUploadedFileIgnoresAFakeUpload(): void {
$file = $this->tmp . '/fake';
file_put_contents($file, self::png());
$_FILES['upload'] = array('tmp_name' => $file, 'name' => 'a.png', 'error' => 0);
$this->assertFalse(save_uploaded_file());
}
public function testPruneDeletesTheSavedFileAndOldUploads(): void {
$saved = $this->pending();
$old = upload_dir() . '/' . str_repeat('c', 32);
$new = upload_dir() . '/' . str_repeat('d', 32);
$other = upload_dir() . '/keep-me';
foreach (array($old, $new, $other) as $file)
file_put_contents($file, 'x');
touch($old, time() - 86401);
touch($other, time() - 86401);
prune_images($saved);
$this->assertFileDoesNotExist($saved);
$this->assertFileDoesNotExist($old);
$this->assertFileExists($new);
$this->assertFileExists($other);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testPruneLeavesFilesOutsideTheUploadDirectory(): void {
$outside = $this->tmp . '/' . str_repeat('e', 32);
file_put_contents($outside, 'x');
prune_images($outside);
$this->assertFileExists($outside);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeRecaptcha;
use Xxx\Tests\Support\TestCase;
// validate_data() in publish/post.lib.php
class ValidateDataTest extends TestCase {
private function validate(array $post, bool $captcha = TRUE): array {
if ($captcha)
$_SESSION['captcha_ok'] = time();
$this->request(array(), $post + array('title' => 'Title', 'author' => 'Ann', 'story' => '<p>x</p>'));
$this->assertTrue(validate_data());
return $GLOBALS['validate'];
}
public function testACompleteStoryPasses(): void {
$this->assertSame(array(), $this->validate(array()));
}
public function testTitleAndAuthorAreNeeded(): void {
$this->assertSame(array('author' => 'You have not submitted an author.', 'title' => 'You have not submitted a title.'),
$this->validate(array('title' => '', 'author' => '')));
}
public function testArraysCountAsEmpty(): void {
$this->assertArrayHasKey('title', $this->validate(array('title' => array('x'))));
}
public function testRemovingTheImageNeedsText(): void {
$this->assertSame(array('story' => "You've tried to remove an image without replacing it with text."),
$this->validate(array('remove_img' => '1', 'story' => '')));
$GLOBALS['validate'] = array();
$this->assertSame(array(), $this->validate(array('remove_img' => '1')));
}
public function testVisitorsNeedTheCaptcha(): void {
FakeRecaptcha::install('{"success": false}');
$this->assertSame(array('captcha' => 'Please complete the captcha.'), $this->validate(array('g-recaptcha-response' => 'r'), FALSE));
}
public function testASolvedCaptchaIsRemembered(): void {
FakeRecaptcha::install('{"success": true}');
$this->assertSame(array(), $this->validate(array('g-recaptcha-response' => 'r'), FALSE));
$this->assertGreaterThan(time() - 5, $_SESSION['captcha_ok']);
}
public function testAnOldCaptchaPassDoesNotCount(): void {
FakeRecaptcha::install('{"success": false}');
$_SESSION['captcha_ok'] = time() - CAPTCHA_PASS_SECONDS - 1;
$this->assertArrayHasKey('captcha', $this->validate(array(), FALSE));
}
public function testEditorsNeedNoCaptcha(): void {
FakeRecaptcha::install('{"success": false}');
$this->asEditor();
$this->assertSame(array(), $this->validate(array(), FALSE));
$this->assertSame(array(), FakeRecaptcha::$requests);
}
public function testUploadErrorsAreReported(): void {
$_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => '', 'error' => UPLOAD_ERR_INI_SIZE);
$this->assertSame(array('upload' => 'There was an error uploading the file (1)'), $this->validate(array()));
}
public function testNoFileIsNotAnError(): void {
$_FILES['upload'] = array('name' => '', 'tmp_name' => '', 'error' => UPLOAD_ERR_NO_FILE);
$this->assertSame(array(), $this->validate(array()));
}
public function testTheUploadMustBeAnImage(): void {
$file = $this->tmp . '/upload';
file_put_contents($file, 'This is not an image, but it is long enough to read.');
$_FILES['upload'] = array('name' => '<b>notes</b>.txt', 'tmp_name' => $file, 'error' => UPLOAD_ERR_OK);
$this->assertSame(array('upload' => "&lt;b&gt;notes&lt;/b&gt;.txt wasn't an image file. Please go back."), $this->validate(array()));
}
public function testAnImageUploadPasses(): void {
$file = $this->tmp . '/upload';
file_put_contents($file, self::png());
$_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => $file, 'error' => UPLOAD_ERR_OK);
$this->assertSame(array(), $this->validate(array()));
}
}