Add PHP unit tests and page tests.
Run "composer install", then "composer test".
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user