Add PHP unit tests and page tests.
Run "composer install", then "composer test".
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
|
||||
// publish/addeditor.php
|
||||
class AddEditorTest extends PageTestCase {
|
||||
private string $token;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->setTables(array('editors' => array(array('username' => 'ed', 'password' => 'x', 'email' => ''))));
|
||||
$this->token = $this->loginAs();
|
||||
}
|
||||
|
||||
private function add(array $fields = array()): array {
|
||||
return $this->post('/publish/addeditor', $fields + array('adduser' => '1', 'username' => 'newbie', 'password' => 'pass word',
|
||||
'email' => 'new@example.org', 'csrf_token' => $this->token));
|
||||
}
|
||||
|
||||
public function testVisitorsAreSentToTheLogin(): void {
|
||||
$this->cookies = array();
|
||||
$this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/addeditor'));
|
||||
$this->assertRedirect('https://xxx.org.za/publish/login', $this->add(array('csrf_token' => '')));
|
||||
$this->assertCount(1, $this->tables()['editors']);
|
||||
}
|
||||
|
||||
public function testPlainHttpIsSentToHttps(): void {
|
||||
$this->assertRedirect('https://xxx.org.za/publish/addeditor', $this->get('/publish/addeditor', array('X-Test-Plain' => '1')));
|
||||
}
|
||||
|
||||
public function testTheFormHasTheToken(): void {
|
||||
$this->assertSame($this->token, $this->csrfToken($this->get('/publish/addeditor')['body']));
|
||||
}
|
||||
|
||||
public function testAnEditorIsAddedWithAPasswordHash(): void {
|
||||
$answer = $this->add();
|
||||
$this->assertStringContainsString('<p>Added editor newbie</p>', $answer['body']);
|
||||
$row = $this->tables()['editors'][1];
|
||||
$this->assertSame(array('newbie', 'new@example.org'), array($row['username'], $row['email']));
|
||||
$this->assertTrue(password_verify('pass word', $row['password']));
|
||||
}
|
||||
|
||||
public function testTheTokenIsChecked(): void {
|
||||
$answer = $this->add(array('csrf_token' => 'wrong'));
|
||||
$this->assertStringContainsString('Your session has expired. Please try again.', $answer['body']);
|
||||
$this->assertCount(1, $this->tables()['editors']);
|
||||
}
|
||||
|
||||
public function testAnExistingUsernameIsRefused(): void {
|
||||
$answer = $this->add(array('username' => 'ed'));
|
||||
$this->assertStringContainsString('An editor with that username already exists.', $answer['body']);
|
||||
$this->assertSame('x', $this->tables()['editors'][0]['password']);
|
||||
}
|
||||
|
||||
public function testAHashThatDoesNotFitIsNotKept(): void {
|
||||
$this->setTables(array('editors' => array()), 40);
|
||||
$answer = $this->add();
|
||||
$this->assertStringContainsString('the editors.password column is too short', $answer['body']);
|
||||
$this->assertSame(array(), $this->tables()['editors']);
|
||||
}
|
||||
|
||||
public function testTheUsernameIsEscaped(): void {
|
||||
$body = $this->add(array('username' => '<b>x</b>'))['body'];
|
||||
$this->assertStringContainsString('Added editor <b>x</b>', $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
|
||||
// publish/edit.php, for editors
|
||||
class EditTest extends PageTestCase {
|
||||
private string $token;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->setTables(array(
|
||||
'xxx' => array(array('id' => 1, 'title' => 'Live title', 'date' => '2021-01-01 10:00:00'),
|
||||
array('id' => 2, 'title' => 'Other live', 'date' => '2021-01-02 10:00:00')),
|
||||
'queue' => array(array('id' => 5, 'title' => 'Queued title', 'date' => '2021-02-01 10:00:00')),
|
||||
));
|
||||
$this->token = $this->loginAs();
|
||||
}
|
||||
|
||||
private function story(array $fields): array {
|
||||
return $fields + array('title' => 'Changed', 'author' => 'Ann', 'story' => '<p>New text.</p>', 'csrf_token' => $this->token);
|
||||
}
|
||||
|
||||
public function testVisitorsAreSentToTheLogin(): void {
|
||||
$this->cookies = array();
|
||||
$this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/edit'));
|
||||
}
|
||||
|
||||
public function testPlainHttpIsSentToHttps(): void {
|
||||
$this->assertRedirect('https://xxx.org.za/publish/edit?id=1', $this->get('/publish/edit?id=1', array('X-Test-Plain' => '1')));
|
||||
}
|
||||
|
||||
public function testTheQueueIsShownFirst(): void {
|
||||
$body = $this->get('/publish/edit')['body'];
|
||||
$this->assertStringContainsString('Queued title', $body);
|
||||
$this->assertStringNotContainsString('Live title', $body);
|
||||
$this->assertStringContainsString('<a href="edit?queue=0">click here</a>', $body);
|
||||
}
|
||||
|
||||
public function testTheLiveStoriesCanBeChosen(): void {
|
||||
$body = $this->get('/publish/edit?queue=0')['body'];
|
||||
$this->assertStringContainsString('Live title', $body);
|
||||
$this->assertStringContainsString('Other live', $body);
|
||||
$this->assertStringNotContainsString('Queued title', $body);
|
||||
}
|
||||
|
||||
public function testAStoryOpensInTheForm(): void {
|
||||
$body = $this->get('/publish/edit?id=1&queue=0')['body'];
|
||||
$this->assertStringContainsString('<input type="text" id="title" name="title" maxlength="150" value="Live title" />', $body);
|
||||
$this->assertStringContainsString('<input type="hidden" id="editor" name="editor" value="ed" />', $body);
|
||||
$this->assertStringContainsString('value="delete"', $body);
|
||||
$this->assertSame($this->token, $this->csrfToken($body));
|
||||
}
|
||||
|
||||
public function testEditorsUpdateALiveStory(): void {
|
||||
$page = $this->post('/publish/edit', $this->story(array('action' => 'post', 'id' => '1', 'queue' => '0')));
|
||||
$this->assertSame(200, $page['status']);
|
||||
$this->assertSame('Changed', $this->tables()['xxx'][0]['title']);
|
||||
$this->assertSame(array(), $this->mails());
|
||||
}
|
||||
|
||||
public function testPostingFromTheQueueMovesTheStory(): void {
|
||||
$this->post('/publish/edit', $this->story(array('action' => 'post', 'id' => '5', 'queue' => '1')));
|
||||
$tables = $this->tables();
|
||||
$this->assertSame(array(), $tables['queue']);
|
||||
$this->assertSame(array('Live title', 'Other live', 'Changed'), array_column($tables['xxx'], 'title'));
|
||||
}
|
||||
|
||||
public function testSavingKeepsTheStoryInTheQueue(): void {
|
||||
$this->post('/publish/edit', $this->story(array('action' => 'save', 'id' => '5', 'queue' => '1', 'comments' => 'Needs work')));
|
||||
$row = $this->tables()['queue'][0];
|
||||
$this->assertSame(array('5', 'Changed', 'Needs work'), array($row['id'], $row['title'], $row['comments']));
|
||||
}
|
||||
|
||||
public function testDeletingNeedsTheToken(): void {
|
||||
$page = $this->post('/publish/edit', array('action' => 'delete', 'id' => '1', 'queue' => '0', 'csrf_token' => 'wrong'));
|
||||
$this->assertStringContainsString('Your session has expired.', $page['body']);
|
||||
$this->assertCount(2, $this->tables()['xxx']);
|
||||
$this->assertSame(array(), $this->queries('/^DELETE/'));
|
||||
}
|
||||
|
||||
public function testDeletingAStory(): void {
|
||||
$page = $this->post('/publish/edit', array('action' => 'delete', 'id' => '1', 'queue' => '0', 'csrf_token' => $this->token));
|
||||
$this->assertStringContainsString('Article has been deleted.', $page['body']);
|
||||
$this->assertSame(array('Other live'), array_column($this->tables()['xxx'], 'title'));
|
||||
}
|
||||
|
||||
public function testDeletingCannotBeDoneByGet(): void {
|
||||
$this->get('/publish/edit?action=delete&id=1&queue=0&csrf_token=' . $this->token);
|
||||
$this->assertCount(2, $this->tables()['xxx']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
use Xxx\Tests\Support\TestCase;
|
||||
|
||||
// feed.php and display.php
|
||||
class FeedAndImageTest extends PageTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->setTables(array(
|
||||
'xxx' => array(
|
||||
array('id' => 1, 'title' => 'Tom & "Jerry"', 'author' => 'Ann <ann>', 'date' => '2021-01-03 10:00:00',
|
||||
'story' => "<p>Wait… a <strong>bold</strong>\x01 move & more.</p>"),
|
||||
array('id' => 2, 'title' => 'Picture', 'date' => '2021-01-02 10:00:00', 'story' => '', 'img' => TestCase::png(), 'mime' => 'text/html'),
|
||||
array('id' => 3, 'title' => 'Secret', 'date' => '2021-01-01 10:00:00', 'img' => TestCase::png(), 'mime' => 'image/png', 'hidden' => '1'),
|
||||
array('id' => 4, 'title' => 'Not an image', 'img' => '<script>alert(1)</script>', 'mime' => 'image/png'),
|
||||
),
|
||||
'queue' => array(
|
||||
array('id' => 9, 'title' => 'Waiting', 'img' => TestCase::png(), 'mime' => 'image/png'),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
public function testTheFeedIsValidRss(): void {
|
||||
$feed = $this->get('/feed');
|
||||
$this->assertSame('application/rss+xml', $this->header($feed, 'Content-Type'));
|
||||
$xml = simplexml_load_string($feed['body']);
|
||||
$this->assertNotFalse($xml, $feed['body']);
|
||||
$items = $xml->channel->item;
|
||||
// The front page, then the visible stories, newest first
|
||||
$this->assertSame(array('Front Page', 'Tom & "Jerry"', 'Picture', 'Not an image'),
|
||||
array_map('strval', iterator_to_array($xml->xpath('/rss/channel/item/title'), FALSE)));
|
||||
$this->assertSame('http://xxx.org.za/article?id=1', (string)$items[1]->link);
|
||||
$this->assertSame('Wait… a bold move & more.', (string)$items[1]->description);
|
||||
$this->assertSame('Ann <ann>', (string)$items[1]->children('http://purl.org/dc/elements/1.1/')->creator);
|
||||
$this->assertSame('Image: Picture', (string)$items[2]->description);
|
||||
$this->assertSame(date('D, d M Y H:i:s O', strtotime('2021-01-03 10:00:00')), (string)$xml->channel->lastBuildDate);
|
||||
}
|
||||
|
||||
public function testAnImageIsServedWithItsRealType(): void {
|
||||
$image = $this->get('/display?id=2');
|
||||
$this->assertSame(200, $image['status']);
|
||||
$this->assertSame(TestCase::png(), $image['body']);
|
||||
$this->assertSame('image/png', $this->header($image, 'Content-Type'));
|
||||
$this->assertSame('nosniff', $this->header($image, 'X-Content-Type-Options'));
|
||||
$this->assertSame((string)strlen(TestCase::png()), $this->header($image, 'Content-Length'));
|
||||
}
|
||||
|
||||
public function testDataThatIsNotAnImageIsNotServed(): void {
|
||||
$image = $this->get('/display?id=4');
|
||||
$this->assertSame(404, $image['status']);
|
||||
$this->assertStringNotContainsString('script', $image['body']);
|
||||
}
|
||||
|
||||
public function testAMissingImageIsNotFound(): void {
|
||||
$this->assertSame(404, $this->get('/display?id=99')['status']);
|
||||
$this->assertSame(404, $this->get('/display?id=1')['status']);
|
||||
}
|
||||
|
||||
public function testHiddenImagesAreForEditorsOnly(): void {
|
||||
$this->assertSame(404, $this->get('/display?id=3')['status']);
|
||||
$this->loginAs();
|
||||
$this->assertSame(200, $this->get('/display?id=3')['status']);
|
||||
}
|
||||
|
||||
public function testQueuedImagesNeedPermission(): void {
|
||||
$this->assertSame(401, $this->get('/display?id=9&queue=1')['status']);
|
||||
$this->startSession(array('allow_queue_image' => '8'));
|
||||
$this->assertSame(401, $this->get('/display?id=9&queue=1')['status']);
|
||||
$this->startSession(array('allow_queue_image' => '9'));
|
||||
$this->assertSame(200, $this->get('/display?id=9&queue=1')['status']);
|
||||
$this->loginAs();
|
||||
$this->assertSame(200, $this->get('/display?id=9&queue=1')['status']);
|
||||
}
|
||||
|
||||
public function testThePendingUploadIsShownToItsSession(): void {
|
||||
$name = bin2hex(random_bytes(16));
|
||||
mkdir(self::$state . '/tmp/uploads', 0700);
|
||||
file_put_contents(self::$state . "/tmp/uploads/$name", TestCase::png());
|
||||
$this->assertSame(404, $this->get('/display?pending=x')['status']);
|
||||
$this->startSession(array('pending_upload' => $name));
|
||||
$image = $this->get('/display?pending=' . substr($name, 0, 8));
|
||||
$this->assertSame(200, $image['status']);
|
||||
$this->assertSame(TestCase::png(), $image['body']);
|
||||
$this->assertSame('private, no-store', $this->header($image, 'Cache-Control'));
|
||||
}
|
||||
|
||||
public function testAPendingUploadMustBeAnImage(): void {
|
||||
$name = bin2hex(random_bytes(16));
|
||||
mkdir(self::$state . '/tmp/uploads', 0700);
|
||||
file_put_contents(self::$state . "/tmp/uploads/$name", '<html>This is not an image at all.</html>');
|
||||
$this->startSession(array('pending_upload' => $name));
|
||||
$this->assertSame(404, $this->get('/display?pending=x')['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
|
||||
// The public pages: the front page, the archive, a story and about
|
||||
class FrontPagesTest extends PageTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->setTables(array('xxx' => array(
|
||||
array('id' => 1, 'title' => 'Newest', 'author' => 'Ann', 'date' => '2021-01-03 10:00:00', 'story' => '<p>First <em>story</em>.</p>'),
|
||||
array('id' => 2, 'title' => 'Middle', 'author' => 'Bo', 'date' => '2021-01-02 10:00:00', 'story' => '<p onclick="x()">Second.</p>'),
|
||||
array('id' => 3, 'title' => 'Secret', 'author' => 'Cy', 'date' => '2021-01-01 10:00:00', 'story' => '<p>Hidden.</p>', 'hidden' => '1'),
|
||||
)));
|
||||
}
|
||||
|
||||
public function testFrontPageListsTheVisibleStories(): void {
|
||||
$page = $this->get('/');
|
||||
$this->assertSame(200, $page['status']);
|
||||
$this->assertStringContainsString('<a href="/article?id=1">Newest</a>', $page['body']);
|
||||
$this->assertStringContainsString('<a href="/article?id=2">Middle</a>', $page['body']);
|
||||
$this->assertStringNotContainsString('Secret', $page['body']);
|
||||
$this->assertStringNotContainsString('onclick', $page['body']);
|
||||
$this->assertStringEndsWith("</html>\n", $page['body']);
|
||||
}
|
||||
|
||||
public function testPagesAreNotCached(): void {
|
||||
$page = $this->get('/');
|
||||
$this->assertStringContainsString('no-store', implode(', ', $page['headers']['cache-control']));
|
||||
}
|
||||
|
||||
public function testTheSessionCookieIsSecure(): void {
|
||||
$cookie = $this->header($this->get('/'), 'Set-Cookie');
|
||||
$this->assertMatchesRegularExpression('/^PHPSESSID=[0-9a-z,-]+;/', $cookie);
|
||||
foreach (array('path=/', 'domain=.xxx.org.za', 'secure', 'HttpOnly', 'SameSite=Lax') as $flag)
|
||||
$this->assertStringContainsString("; $flag", $cookie);
|
||||
}
|
||||
|
||||
public function testPlainHttpStartsNoSession(): void {
|
||||
$page = $this->get('/', array('X-Test-Plain' => '1'));
|
||||
$this->assertSame(200, $page['status']);
|
||||
$this->assertArrayNotHasKey('set-cookie', $page['headers']);
|
||||
}
|
||||
|
||||
public function testNoAnalyticsOrEditorLinksForVisitors(): void {
|
||||
$body = $this->get('/')['body'];
|
||||
$this->assertStringNotContainsString('google-analytics', $body);
|
||||
$this->assertStringNotContainsString('/publish/logout', $body);
|
||||
$this->assertStringNotContainsString('/publish/addeditor', $body);
|
||||
}
|
||||
|
||||
public function testEditorsSeeHiddenStoriesAndTheirLinks(): void {
|
||||
$token = $this->loginAs();
|
||||
$body = $this->get('/')['body'];
|
||||
$this->assertStringContainsString('Secret', $body);
|
||||
$this->assertStringContainsString('<a href="/publish/logout?token=' . $token . '">Logout</a>', $body);
|
||||
$this->assertStringContainsString('<a href="/publish/addeditor">Add editor</a>', $body);
|
||||
}
|
||||
|
||||
public function testTheArticlePageShowsOneStory(): void {
|
||||
$body = $this->get('/article?id=1')['body'];
|
||||
$this->assertStringContainsString('<p>First <em>story</em>.</p>', $body);
|
||||
$this->assertStringNotContainsString('Middle', $body);
|
||||
}
|
||||
|
||||
public function testAHiddenStoryIsNotShown(): void {
|
||||
$body = $this->get('/article?id=3')['body'];
|
||||
$this->assertStringNotContainsString('Hidden.', $body);
|
||||
}
|
||||
|
||||
public function testSearchFindsStories(): void {
|
||||
$body = $this->get('/article?search=second')['body'];
|
||||
$this->assertStringContainsString('Middle', $body);
|
||||
$this->assertStringNotContainsString('Newest', $body);
|
||||
$this->assertSame(array('%second%'), array_slice($this->queries('/LIKE/')[0]['params'], 0, 1));
|
||||
}
|
||||
|
||||
public function testTheSearchTermIsEscaped(): void {
|
||||
$body = $this->get('/article?search=' . rawurlencode('"><script>x</script>'))['body'];
|
||||
$this->assertStringNotContainsString('<script>x</script>', $body);
|
||||
}
|
||||
|
||||
public function testThePageRemembersWhereTheVisitorWas(): void {
|
||||
$this->get('/article?id=2');
|
||||
$this->assertSame('/article?id=2', $this->session()['referrer']);
|
||||
}
|
||||
|
||||
public function testTheAboutPageLoads(): void {
|
||||
$page = $this->get('/about');
|
||||
$this->assertSame(200, $page['status']);
|
||||
$this->assertStringContainsString('</html>', $page['body']);
|
||||
}
|
||||
|
||||
public function testTheTestsAreNotServed(): void {
|
||||
$this->assertSame(404, $this->get('/tests/php/fixtures/test.env')['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
|
||||
// publish/login.php, publish/logout.php and persistent logins
|
||||
class LoginTest extends PageTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->setTables(array('editors' => array(
|
||||
array('username' => 'ed', 'password' => password_hash('secret', PASSWORD_BCRYPT, array('cost' => 4)), 'email' => 'ed@example.org'),
|
||||
)));
|
||||
}
|
||||
|
||||
// Open the login form, then send it; returns the answer
|
||||
private function login(string $password = 'secret', array $fields = array()): array {
|
||||
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
||||
return $this->post('/publish/login', $fields + array('login' => '1', 'username' => 'ed', 'password' => $password,
|
||||
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
||||
}
|
||||
|
||||
/*
|
||||
* After a login, the site goes back to the last page that the session
|
||||
* showed. With the login form that is the login page itself, which
|
||||
* sends an editor on to the edit page.
|
||||
*/
|
||||
private function assertLoggedIn(array $answer): void {
|
||||
$this->assertRedirect('https://' . $this->host() . '/publish/login', $answer);
|
||||
$this->assertRedirect('https://xxx.org.za/publish/edit', $this->get('/publish/login'));
|
||||
}
|
||||
|
||||
private function host(): string {
|
||||
return parse_url(self::$base, PHP_URL_HOST) . ':' . parse_url(self::$base, PHP_URL_PORT);
|
||||
}
|
||||
|
||||
public function testPlainHttpIsSentToHttps(): void {
|
||||
$this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/login', array('X-Test-Plain' => '1')));
|
||||
}
|
||||
|
||||
public function testTheFormHasATokenAndTheCaptcha(): void {
|
||||
$body = $this->get('/publish/login')['body'];
|
||||
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($body));
|
||||
$this->assertStringContainsString('data-sitekey="test-site-key"', $body);
|
||||
}
|
||||
|
||||
public function testTheRightPasswordLogsIn(): void {
|
||||
$this->get('/publish/login');
|
||||
$before = $this->cookies['PHPSESSID'];
|
||||
$this->assertLoggedIn($this->login('secret', array('remember' => NULL)));
|
||||
// A new session id, and a new CSRF token with it
|
||||
$this->assertNotSame($before, $this->cookies['PHPSESSID']);
|
||||
$session = $this->session();
|
||||
$this->assertSame('ed', $session['editor']);
|
||||
$this->assertArrayNotHasKey('csrf_token', $session);
|
||||
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
|
||||
}
|
||||
|
||||
public function testTheLoginGoesBackToTheLastPage(): void {
|
||||
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
||||
$this->get('/article?id=1');
|
||||
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
||||
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
||||
$this->assertRedirect('https://' . $this->host() . '/article?id=1', $answer);
|
||||
$this->assertArrayNotHasKey('referrer', $this->session());
|
||||
}
|
||||
|
||||
public function testWithoutALastPageTheLoginGoesToTheEditPage(): void {
|
||||
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
||||
$this->startSession(array('csrf_token' => $token));
|
||||
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
||||
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
||||
$this->assertRedirect('https://xxx.org.za/publish/edit', $answer);
|
||||
}
|
||||
|
||||
public function testTheWrongPasswordIsDenied(): void {
|
||||
$answer = $this->login('wrong');
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
|
||||
$this->assertArrayNotHasKey('editor', $this->session());
|
||||
}
|
||||
|
||||
public function testAnUnknownEditorIsDenied(): void {
|
||||
$answer = $this->login('secret', array('username' => 'nobody'));
|
||||
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
|
||||
}
|
||||
|
||||
public function testTheTokenIsChecked(): void {
|
||||
$this->get('/publish/login');
|
||||
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
||||
'csrf_token' => 'wrong', 'g-recaptcha-response' => 'pass'));
|
||||
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
|
||||
$this->assertSame(array(), $this->queries('/FROM editors/'));
|
||||
}
|
||||
|
||||
public function testTheCaptchaIsChecked(): void {
|
||||
$answer = $this->login('secret', array('g-recaptcha-response' => 'fail'));
|
||||
$this->assertStringContainsString('Please complete the captcha.', $answer['body']);
|
||||
$this->assertSame(array(), $this->queries('/FROM editors/'));
|
||||
}
|
||||
|
||||
public function testFiveFailuresBlockTheLogin(): void {
|
||||
for ($i = 0; $i < 5; $i++)
|
||||
$this->login('wrong');
|
||||
$answer = $this->login('secret');
|
||||
$this->assertStringContainsString('Too many failed logins. Please try again in 15 minutes.', $answer['body']);
|
||||
$this->assertArrayNotHasKey('editor', $this->session());
|
||||
}
|
||||
|
||||
public function testTheBlockIsPerAddress(): void {
|
||||
for ($i = 0; $i < 5; $i++)
|
||||
$this->login('wrong');
|
||||
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
||||
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', 'csrf_token' => $token,
|
||||
'g-recaptcha-response' => 'pass'), array('X-Test-Address' => '198.51.100.7'));
|
||||
$this->assertLoggedIn($answer);
|
||||
}
|
||||
|
||||
public function testAnOldHashIsReplacedAtLogin(): void {
|
||||
$this->setTables(array('editors' => array(array('username' => 'ed', 'password' => crypt('secret', '$1$abcdefgh$'), 'email' => ''))));
|
||||
$this->assertLoggedIn($this->login());
|
||||
$hash = $this->tables()['editors'][0]['password'];
|
||||
$this->assertFalse(password_needs_rehash($hash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT));
|
||||
$this->assertTrue(password_verify('secret', $hash));
|
||||
}
|
||||
|
||||
public function testAnEditorWhoIsLoggedInGoesToTheEditPage(): void {
|
||||
$this->loginAs();
|
||||
$this->assertRedirect('https://xxx.org.za/publish/edit', $this->get('/publish/login'));
|
||||
}
|
||||
|
||||
public function testRememberMeKeepsTheEditorLoggedIn(): void {
|
||||
$this->login('secret', array('remember' => 'on'));
|
||||
$this->assertMatchesRegularExpression('/^ed\|[0-9a-f]{64}$/', $this->cookies['PersistentAuth']);
|
||||
$key = explode('|', $this->cookies['PersistentAuth'])[1];
|
||||
$this->assertSame(array(hash('sha256', $key)), array_column($this->tables()['persistent_sessions'], 'session'));
|
||||
|
||||
// A new browser session with only the cookie
|
||||
unset($this->cookies['PHPSESSID']);
|
||||
$body = $this->get('/')['body'];
|
||||
$this->assertStringContainsString('/publish/logout?token=', $body);
|
||||
$this->assertSame('ed', $this->session()['editor']);
|
||||
}
|
||||
|
||||
public function testAWrongPersistentKeyDoesNotLogIn(): void {
|
||||
$this->setTables(array('persistent_sessions' => array(
|
||||
array('username' => 'ed', 'session' => hash('sha256', 'right'), 'created' => gmdate('Y-m-d H:i:s')))));
|
||||
$this->cookies['PersistentAuth'] = 'ed|wrong';
|
||||
$body = $this->get('/')['body'];
|
||||
$this->assertStringNotContainsString('/publish/logout', $body);
|
||||
}
|
||||
|
||||
public function testLogoutNeedsTheToken(): void {
|
||||
$this->loginAs();
|
||||
$this->assertRedirect('https://xxx.org.za/', $this->get('/publish/logout?token=wrong'));
|
||||
$this->assertSame('ed', $this->session()['editor']);
|
||||
}
|
||||
|
||||
public function testLogoutEndsTheSessionAndThePersistentLogin(): void {
|
||||
$this->setTables(array('persistent_sessions' => array(
|
||||
array('username' => 'ed', 'session' => hash('sha256', 'key'), 'created' => gmdate('Y-m-d H:i:s')),
|
||||
array('username' => 'ed', 'session' => hash('sha256', 'other device'), 'created' => gmdate('Y-m-d H:i:s')))));
|
||||
$token = $this->loginAs('ed', array('referrer' => '/article?id=2'));
|
||||
$this->cookies['PersistentAuth'] = 'ed|key';
|
||||
$session = self::$state . '/sessions/sess_' . $this->cookies['PHPSESSID'];
|
||||
|
||||
$answer = $this->get('/publish/logout?token=' . $token);
|
||||
$this->assertRedirect('https://' . $this->host() . '/article?id=2', $answer);
|
||||
$this->assertFileDoesNotExist($session);
|
||||
$this->assertArrayNotHasKey('PHPSESSID', $this->cookies);
|
||||
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
|
||||
$this->assertSame(array(hash('sha256', 'other device')), array_column($this->tables()['persistent_sessions'], 'session'));
|
||||
}
|
||||
|
||||
public function testLogoutDoesNotGoBackToTheLogoutPage(): void {
|
||||
$token = $this->loginAs('ed', array('referrer' => '/publish/logout'));
|
||||
$this->assertRedirect('/', $this->get('/publish/logout?token=' . $token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Pages;
|
||||
|
||||
use Xxx\Tests\Support\PageTestCase;
|
||||
|
||||
// Sending a story from publish/ as a visitor
|
||||
class SubmitTest extends PageTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->setTables(array('xxx' => array(array('id' => 1, 'title' => 'Live'))));
|
||||
}
|
||||
|
||||
private function story(array $fields = array()): array {
|
||||
return $fields + array('title' => 'My story', 'author' => 'Vi', 'email' => 'vi@example.org',
|
||||
'story' => '<p>Once -- upon a "time"<script>x()</script>.</p>');
|
||||
}
|
||||
|
||||
public function testPlainHttpIsSentToHttps(): void {
|
||||
$this->assertRedirect('https://xxx.org.za/publish', $this->get('/publish', array('X-Test-Plain' => '1')));
|
||||
}
|
||||
|
||||
public function testTheFormHasATokenAndTheCaptcha(): void {
|
||||
$page = $this->get('/publish');
|
||||
$this->assertSame(200, $page['status']);
|
||||
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($page['body']));
|
||||
$this->assertStringContainsString('<div class="g-recaptcha" data-sitekey="test-site-key"></div>', $page['body']);
|
||||
$this->assertStringContainsString('https://www.google.com/recaptcha/api.js', $page['body']);
|
||||
$this->assertStringContainsString('<script type="text/javascript" src="../publish/editor.js"></script>', $page['body']);
|
||||
}
|
||||
|
||||
public function testEditorsGetNoCaptcha(): void {
|
||||
$this->loginAs();
|
||||
$body = $this->get('/publish')['body'];
|
||||
$this->assertStringNotContainsString('recaptcha', $body);
|
||||
}
|
||||
|
||||
public function testPreviewThenSubmitAddsTheStoryToTheQueue(): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$preview = $this->post('/publish', $this->story(array('action' => 'preview', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')));
|
||||
$this->assertStringContainsString('Please review your submission.', $preview['body']);
|
||||
// The preview shows the purified story; the typography comes when it is saved
|
||||
$this->assertStringContainsString('<p>Once -- upon a "time".</p>', $preview['body']);
|
||||
$this->assertStringContainsString('<button type="submit" id="submit" name="action" value="submit">', $preview['body']);
|
||||
// The solved captcha lasts until the story is saved
|
||||
$this->assertStringNotContainsString('g-recaptcha', $preview['body']);
|
||||
$this->assertSame(array(), $this->queries('/^INSERT/'));
|
||||
|
||||
$this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => $token)));
|
||||
$tables = $this->tables();
|
||||
$this->assertCount(1, $tables['xxx']);
|
||||
$this->assertCount(1, $tables['queue']);
|
||||
$row = $tables['queue'][0];
|
||||
$this->assertSame(array('My story', 'Vi', 'vi@example.org', '<p>Once — upon a “time”.</p>', 'original author', 0),
|
||||
array($row['title'], $row['author'], $row['email'], $row['story'], $row['editor'], $row['hidden']));
|
||||
$session = $this->session();
|
||||
$this->assertArrayNotHasKey('captcha_ok', $session);
|
||||
$this->assertSame($row['id'], (string)$session['allow_queue_image']);
|
||||
|
||||
$mails = $this->mails();
|
||||
$this->assertCount(1, $mails);
|
||||
$this->assertSame('New submission: My story', $mails[0]['subject']);
|
||||
$this->assertStringContainsString('Once — upon a “time”.', $mails[0]['body']);
|
||||
}
|
||||
|
||||
public function testVisitorsCannotPostOrOverwrite(): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$this->post('/publish', $this->story(array('action' => 'post', 'id' => '1', 'hidden' => 'on', 'editor' => 'ed',
|
||||
'comments' => 'x', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')));
|
||||
$tables = $this->tables();
|
||||
$this->assertSame('Live', $tables['xxx'][0]['title']);
|
||||
$this->assertCount(1, $tables['xxx']);
|
||||
$this->assertCount(1, $tables['queue']);
|
||||
$this->assertSame(array('original author', 0, ''), array($tables['queue'][0]['editor'], $tables['queue'][0]['hidden'], $tables['queue'][0]['comments']));
|
||||
}
|
||||
|
||||
public function testAPostWithoutTheTokenOnlyGetsAPreview(): void {
|
||||
$this->get('/publish');
|
||||
$page = $this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => 'wrong', 'g-recaptcha-response' => 'pass')));
|
||||
$this->assertStringContainsString('Your session has expired. Please check your story, then send it again.', $page['body']);
|
||||
$this->assertSame(array(), $this->queries('/^INSERT/'));
|
||||
$this->assertSame(array(), $this->mails());
|
||||
}
|
||||
|
||||
public function testAFailedCaptchaStopsTheStory(): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$page = $this->post('/publish', $this->story(array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => 'fail')));
|
||||
$this->assertStringContainsString('Please complete the captcha.', $page['body']);
|
||||
$this->assertStringContainsString('g-recaptcha', $page['body']);
|
||||
$this->assertSame(array(), $this->queries('/^INSERT/'));
|
||||
}
|
||||
|
||||
public function testMissingFieldsAreShown(): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$page = $this->post('/publish', array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass', 'story' => '<p>x</p>'));
|
||||
$this->assertStringContainsString('<li>You have not submitted an author.</li>', $page['body']);
|
||||
$this->assertStringContainsString('<label class="red" for="title">', $page['body']);
|
||||
$this->assertSame(array(), $this->queries('/^INSERT/'));
|
||||
}
|
||||
|
||||
public function testFieldsAreEscapedInTheForm(): void {
|
||||
$token = $this->csrfToken($this->get('/publish')['body']);
|
||||
$page = $this->post('/publish', $this->story(array('action' => 'preview', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass',
|
||||
'title' => '"><script>alert(1)</script>')));
|
||||
$this->assertStringNotContainsString('<script>alert(1)</script>', $page['body']);
|
||||
$this->assertStringContainsString('value=""><script>alert(1)</script>"', $page['body']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user