Files
xxx/tests/php/Pages/LoginTest.php
T
tim ed959888b4 Add PHP unit tests and page tests.
Run "composer install", then "composer test".
2026-09-26 03:48:09 +02:00

179 lines
8.6 KiB
PHP

<?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));
}
}