generated from jric11/baseProject
Codex's updates...
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""End-to-end tests against a disposable local database and PHP development server.
|
||||
|
||||
Set HCR_TEST_DSN, HCR_TEST_USER, HCR_TEST_PASSWORD, HCR_DB_HOST,
|
||||
HCR_DB_PORT, HCR_DB_USER, HCR_DB_PASSWORD and PHP_TEST_COMMAND (JSON argv).
|
||||
"""
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PHP = json.loads(os.environ.get('PHP_TEST_COMMAND', '["php"]'))
|
||||
env = os.environ.copy()
|
||||
env['HCR_APPLICATION_URL'] = '' # Never send real email from the test suite.
|
||||
env['HCR_ENVIRONMENT'] = 'Development'
|
||||
checks = []
|
||||
|
||||
def fixture(action, database=None):
|
||||
args = PHP + ['tests/http_fixture.php', action] + ([database] if database else [])
|
||||
result = subprocess.run(args, cwd=ROOT, env=env, check=True, text=True, capture_output=True)
|
||||
return json.loads(result.stdout.splitlines()[-1]) if result.stdout.strip() else {}
|
||||
|
||||
def check(condition, message):
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
checks.append(message)
|
||||
|
||||
jar = http.cookiejar.CookieJar()
|
||||
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
def request(path='/', data=None, headers=None):
|
||||
encoded = urllib.parse.urlencode(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(base + path, data=encoded, headers=headers or {})
|
||||
try:
|
||||
with client.open(req, timeout=20) as response:
|
||||
return response.status, response.read().decode(), response.headers
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.read().decode(), error.headers
|
||||
|
||||
def csrf(html):
|
||||
match = re.search(r'name="csrf_token"[^>]*value="([a-f0-9]+)"', html)
|
||||
if not match:
|
||||
match = re.search(r'name="csrf-token"[^>]*content="([a-f0-9]+)"', html)
|
||||
if not match:
|
||||
raise AssertionError('Missing CSRF token')
|
||||
return match.group(1)
|
||||
|
||||
database = fixture('create')['database']
|
||||
env['HCR_DB_NAME'] = database
|
||||
with socket.socket() as available:
|
||||
available.bind(('127.0.0.1', 0))
|
||||
port = available.getsockname()[1]
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
server = None
|
||||
try:
|
||||
result = subprocess.run(PHP + ['bin/migrate-auth.php'], cwd=ROOT, env=env, check=True, capture_output=True, text=True)
|
||||
check('schema is ready' in result.stdout, 'Database migration can be rerun safely')
|
||||
with tempfile.TemporaryFile(mode='w+b') as log:
|
||||
server = subprocess.Popen(PHP + ['-S', f'127.0.0.1:{port}', 'bin/dev-router.php'], cwd=ROOT, env=env, stdout=log, stderr=log)
|
||||
for _ in range(50):
|
||||
try:
|
||||
status, html, headers = request()
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise AssertionError('PHP development server did not start')
|
||||
check(status == 200 and 'name="user_email"' in html, 'Login page renders over HTTP')
|
||||
check('no-store' in headers.get('Cache-Control', ''), 'Authentication pages are not cached')
|
||||
check('HttpOnly' in headers.get('Set-Cookie', '') and 'SameSite=Lax' in headers.get('Set-Cookie', ''), 'Session cookies have HttpOnly and SameSite protections')
|
||||
check(request(data={'action': 'SignIn.login', 'user_email': 'http@example.test', 'user_password': 'wrong'})[0] == 403, 'Login rejects missing CSRF token')
|
||||
check(request(data={'action[]': 'bad'})[0] == 400, 'Array-shaped request fields are rejected')
|
||||
status, html, _ = request('/?Xaction=signup')
|
||||
check(status == 200 and 'name="user_first_name"' in html, 'Signup is reachable before login')
|
||||
fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator'}
|
||||
status, html, _ = request(data=fields)
|
||||
check(status == 200 and 'Your account has been created' in html, 'Signup form creates an account and redirects')
|
||||
status, html, _ = request()
|
||||
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'wrong'})
|
||||
check(status == 200 and 'Invalid email address or password' in html, 'Invalid login renders the existing login template')
|
||||
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP long password!'})
|
||||
check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell')
|
||||
token = csrf(html)
|
||||
check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator'})[0] == 403, 'Regular users cannot edit account privileges')
|
||||
check(request(data={'action': 'print', 'report': 'Email', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
|
||||
check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched')
|
||||
check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token')
|
||||
status, html, _ = request(data={'action': 'signout', 'csrf_token': token})
|
||||
check(status == 200 and 'id="login"' in html, 'Logout ends the authenticated session')
|
||||
status, html, _ = request('/?Xaction=resetpassword')
|
||||
check('Send reset link' in html, 'Password reset request page is reachable before login')
|
||||
status, html, _ = request(data={'action': 'resetpassword', 'csrf_token': csrf(html), 'user_email': 'unknown@example.test'})
|
||||
check(status == 200 and 'If an active account matches' in html, 'Unknown account reset returns a neutral response')
|
||||
status, form, _ = request('/?Xaction=resetpassword')
|
||||
status, html, _ = request(data={'action': 'resetpassword', 'csrf_token': csrf(form), 'user_email': 'http@example.test'})
|
||||
check('If an active account matches' in html, 'Reset delivery/configuration failure returns the same neutral response')
|
||||
reset_token = fixture('token', database)['token']
|
||||
status, html, headers = request('/?Xaction=resetpassword&token=' + reset_token)
|
||||
check(status == 200 and 'Set new password' in html and headers.get('Referrer-Policy') == 'no-referrer', 'Reset link renders a protected new-password form')
|
||||
status, html, _ = request(data={'action': 'resetpassword', 'step': 'reset', 'token': reset_token, 'csrf_token': csrf(html), 'user_password': 'HTTP changed password!', 'user_password_confirm': 'HTTP changed password!'})
|
||||
check(status == 200 and 'Your password has been changed' in html, 'Reset form changes the password and redirects')
|
||||
status, html, _ = request('/?Xaction=resetpassword&token=' + reset_token)
|
||||
status, html, _ = request(data={'action': 'resetpassword', 'step': 'reset', 'token': reset_token, 'csrf_token': csrf(html), 'user_password': 'HTTP changed password!', 'user_password_confirm': 'HTTP changed password!'})
|
||||
check('invalid or expired' in html, 'Used reset link fails through the real HTTP handler')
|
||||
status, html, _ = request()
|
||||
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP changed password!'})
|
||||
check(status == 200 and 'name="csrf-token"' in html, 'Reset password works through the real login form')
|
||||
fixture('revoke', database)
|
||||
check(request(data={'action': 'toggleTheme', 'csrf_token': csrf(html)}, headers={'X-Requested-With': 'XMLHttpRequest'})[0] == 401, 'Revoked AJAX sessions return 401 instead of a login document')
|
||||
for path in ['/xml/settings.local.xml', '/tables/_createSystemTable.php', '/bin/migrate-auth.php', '/.git/config']:
|
||||
check(request(path)[0] == 404, 'Development router blocks ' + path)
|
||||
for message in checks:
|
||||
print('PASS:', message)
|
||||
print(len(checks), 'HTTP checks passed.')
|
||||
finally:
|
||||
if server:
|
||||
server.terminate()
|
||||
server.wait(timeout=10)
|
||||
fixture('drop', database)
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/** Destructive only inside a new, randomly named test database. Never reads project DB credentials. */
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||
require_once __DIR__ . '/../classes/Base.php';
|
||||
if (!getenv('HCR_TEST_DSN')) throw new RuntimeException('Set HCR_TEST_DSN to an isolated MySQL/MariaDB server (without dbname).');
|
||||
$server = new PDO(getenv('HCR_TEST_DSN'), getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$database = 'hcr_auth_test_' . bin2hex(random_bytes(6));
|
||||
$server->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$server->exec("USE `{$database}`");
|
||||
$server->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$server->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
|
||||
session_start();
|
||||
|
||||
class TestAuth extends AuthService {
|
||||
public array $mailbox = [];
|
||||
public bool $mailWorks = true;
|
||||
public function __construct(private PDO $db) { parent::__construct(); }
|
||||
public function connect(): PDO { return $this->db; }
|
||||
public function getSettings() { return new SimpleXMLElement('<Settings><ApplicationUrl>https://roster.example.test/club</ApplicationUrl></Settings>'); }
|
||||
protected function deliverResetEmail(array $user, string $url): bool {
|
||||
$this->mailbox[] = ['email' => $user['user_email'], 'url' => $url];
|
||||
return $this->mailWorks;
|
||||
}
|
||||
}
|
||||
class CaptureMailer extends Email {
|
||||
public array $message = [];
|
||||
public function sendEmail($to = '', $subject = '', $message = '', $attachments = '', $attachment_names = 'unknown') {
|
||||
$this->message = ['to' => $to, 'subject' => $subject, 'body' => $message];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$checks = [];
|
||||
function check(bool $condition, string $message): void {
|
||||
global $checks;
|
||||
if (!$condition) throw new RuntimeException('FAILED: ' . $message);
|
||||
$checks[] = $message;
|
||||
}
|
||||
function rejects(callable $operation, string $message): void {
|
||||
try { $operation(); } catch (InvalidArgumentException $e) { check(true, $message); return; }
|
||||
check(false, $message);
|
||||
}
|
||||
function tokenFrom(array $email): string {
|
||||
parse_str(parse_url($email['url'], PHP_URL_QUERY), $query);
|
||||
return $query['token'];
|
||||
}
|
||||
try {
|
||||
require __DIR__ . '/../tables/users.php';
|
||||
$server->exec($SQL);
|
||||
// Exercise the upgrade path from the supplied pre-change schema too.
|
||||
$server->exec('ALTER TABLE users DROP COLUMN user_auth_version');
|
||||
foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) {
|
||||
if (trim($sql)) $server->exec($sql);
|
||||
}
|
||||
require __DIR__ . '/../views/view_users.php';
|
||||
$server->exec($SQL);
|
||||
$auth = new TestAuth($server);
|
||||
$auth->initializeSession();
|
||||
$input = ['user_first_name' => 'Jane', 'user_last_name' => 'Hunter', 'user_email' => 'jane@example.test', 'user_name' => 'jane', 'user_password' => 'A long password 123!', 'user_password_confirm' => 'A long password 123!', 'user_role' => 'Administrator'];
|
||||
$auth->register($input);
|
||||
$user = $auth->findUser('jane@example.test');
|
||||
$id = (int) $user['user_serial'];
|
||||
check($user['user_role'] === 'User', 'Signup cannot assign administrator privileges');
|
||||
check(password_verify($input['user_password'], $user['user_password_hash']), 'Signup stores a verifiable password hash');
|
||||
check($user['user_password_hash'] !== $input['user_password'], 'Signup does not store plaintext passwords');
|
||||
rejects(fn() => $auth->register($input), 'Duplicate account details are rejected');
|
||||
rejects(fn() => $auth->register(array_replace($input, ['user_email' => 'invalid'])), 'Invalid email is rejected on the server');
|
||||
rejects(fn() => $auth->register(array_replace($input, ['user_password_confirm' => 'different'])), 'Mismatched passwords are rejected on the server');
|
||||
rejects(fn() => $auth->register(array_replace($input, ['user_password' => 'short', 'user_password_confirm' => 'short'])), 'Short passwords are rejected');
|
||||
rejects(fn() => $auth->register(array_replace($input, ['user_password' => str_repeat('x', 73), 'user_password_confirm' => str_repeat('x', 73)])), 'Passwords above bcrypt byte limit are rejected');
|
||||
check(!$auth->login("' OR 1=1 -- ", 'wrong'), 'SQL injection input cannot log in');
|
||||
check(!$auth->login('jane@example.test', 'wrong'), 'Wrong password cannot log in');
|
||||
$oldSession = session_id();
|
||||
check($auth->login('jane@example.test', $input['user_password']), 'Email login succeeds');
|
||||
check(session_id() !== $oldSession, 'Login rotates session ID');
|
||||
check($auth->currentUser() !== null, 'Authenticated session is valid');
|
||||
check($server->query('SELECT user_last_login FROM users')->fetchColumn() !== null, 'Login timestamp uses the correct column');
|
||||
check($auth->login('jane', $input['user_password']), 'Username login remains supported');
|
||||
$before = $auth->findUser('jane')['user_password_hash'];
|
||||
$auth->requestReset('jane@example.test');
|
||||
check(count($auth->mailbox) === 1, 'Reset request generates an email');
|
||||
check($auth->findUser('jane')['user_password_hash'] === $before, 'Reset request leaves password unchanged');
|
||||
$token = tokenFrom($auth->mailbox[0]);
|
||||
check(strlen($token) === 64, 'Reset token contains 256 bits of randomness');
|
||||
check($server->query('SELECT token_hash FROM password_resets')->fetchColumn() === hash('sha256', $token), 'Database stores only a hash of the reset token');
|
||||
$auth->requestReset('missing@example.test');
|
||||
check(count($auth->mailbox) === 1, 'Unknown accounts do not produce reset email');
|
||||
check(!$auth->resetPassword(str_repeat('0', 64), 'Another long password!', 'Another long password!'), 'Unknown reset token is rejected');
|
||||
$server->exec('UPDATE password_resets SET expires_at = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 SECOND)');
|
||||
check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Expired reset token is rejected');
|
||||
$auth->requestReset('jane@example.test');
|
||||
$token = tokenFrom($auth->mailbox[1]);
|
||||
check($auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Valid reset token changes password');
|
||||
check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Reset token cannot be reused');
|
||||
check($auth->currentUser() === null, 'Password reset revokes an existing session');
|
||||
check(!$auth->login('jane', $input['user_password']), 'Old password fails after reset');
|
||||
check($auth->login('jane', 'Another long password!'), 'New password works after reset');
|
||||
rejects(fn() => $auth->changePassword($id, 'Another long password!', 'New password 123!', 'mismatch'), 'Password change rejects mismatched confirmation');
|
||||
check(!$auth->changePassword($id, 'wrong', 'New password 123!', 'New password 123!'), 'Password change requires the current password');
|
||||
check($auth->changePassword($id, 'Another long password!', 'New password 123!', 'New password 123!'), 'Authenticated password change succeeds');
|
||||
check($auth->currentUser() === null, 'Password change revokes previous sessions');
|
||||
check($auth->login('jane', 'New password 123!'), 'Changed password works');
|
||||
$_SESSION['last_activity'] = time() - 1801;
|
||||
check($auth->currentUser() === null, 'Idle sessions expire on the server');
|
||||
$server->exec("UPDATE users SET user_status = 'inactive'");
|
||||
check(!$auth->login('jane', 'New password 123!'), 'Inactive users cannot log in');
|
||||
$server->exec("UPDATE users SET user_status = 'active'");
|
||||
$auth->mailWorks = false;
|
||||
$beforeFailedEmail = $auth->findUser('jane')['user_password_hash'];
|
||||
try { $auth->requestReset('jane@example.test'); check(false, 'Mail failure must be detected'); }
|
||||
catch (RuntimeException $e) { check($e->getMessage() === 'Password reset email delivery failed.', 'Email failure is detected'); }
|
||||
check((int) $server->query('SELECT COUNT(*) FROM password_resets')->fetchColumn() === 0, 'Failed email delivery removes its token');
|
||||
check($auth->findUser('jane')['user_password_hash'] === $beforeFailedEmail, 'Email failure preserves the existing password');
|
||||
$mailer = new CaptureMailer();
|
||||
$mailer->sendPasswordReset(['user_name' => 'jane', 'user_first_name' => '<b>Jane & club</b>', 'user_email' => 'jane@example.test'], 'https://app.myhuntingclubroster.com/main.php?Xaction=resetpassword&token=example');
|
||||
check($mailer->message['to'] === 'jane@example.test', 'Reset email uses the stored account email');
|
||||
check(str_contains($mailer->message['body'], 'https://app.myhuntingclubroster.com/main.php'), 'Reset email contains the configured application link');
|
||||
check(str_contains($mailer->message['body'], '<b>Jane & club</b>'), 'Reset email escapes account names');
|
||||
check(!str_contains($mailer->message['body'], 'New password 123!'), 'Reset email never includes an account password');
|
||||
for ($i = 0; $i < 3; $i++) $allowed = $auth->allowAttempt('test', 'address', 2, 60);
|
||||
check(!$allowed, 'Rate limits reject excessive attempts');
|
||||
$server->exec('UPDATE auth_attempts SET window_started = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR)');
|
||||
check($auth->allowAttempt('test', 'address', 2, 60), 'Rate limits recover after their window');
|
||||
$freshAuth = new TestAuth($server);
|
||||
for ($i = 0; $i < 11; $i++) $allowed = $freshAuth->login('jane', 'wrong');
|
||||
check(!$freshAuth->login('jane', 'New password 123!'), 'Login rate limits survive new service instances');
|
||||
foreach (['login', 'createAccount', 'resetPassword', 'changePassword', 'content'] as $template) {
|
||||
$html = $auth->applyXSL('<XML/>', $auth->getXSL($template));
|
||||
check(is_string($html) && strlen($html) > 100, $template . ' stylesheet renders');
|
||||
}
|
||||
foreach ($checks as $message) echo "PASS: {$message}\n";
|
||||
echo count($checks) . " checks passed.\n";
|
||||
} finally {
|
||||
$server->exec("DROP DATABASE `{$database}`");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||
require_once __DIR__ . '/../classes/Base.php';
|
||||
$dsn = getenv('HCR_TEST_DSN');
|
||||
if (!$dsn) throw new RuntimeException('Set HCR_TEST_DSN to an isolated test server.');
|
||||
$db = new PDO($dsn, getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$action = $argv[1] ?? '';
|
||||
$name = $argv[2] ?? ('hcr_http_test_' . bin2hex(random_bytes(6)));
|
||||
if (!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/', $name)) throw new RuntimeException('Only generated test databases may be used.');
|
||||
if ($action === 'drop') { $db->exec("DROP DATABASE `{$name}`"); exit; }
|
||||
if ($action === 'create') {
|
||||
$db->exec("CREATE DATABASE `{$name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$db->exec("USE `{$name}`");
|
||||
require __DIR__ . '/../tables/users.php'; $db->exec($SQL);
|
||||
// The CLI migration must also support being run against a fresh users table.
|
||||
putenv('HCR_DB_NAME=' . $name);
|
||||
require __DIR__ . '/../bin/migrate-auth.php';
|
||||
// Idempotent migration is verified separately by the HTTP test runner.
|
||||
echo json_encode(['database' => $name]);
|
||||
exit;
|
||||
}
|
||||
$db->exec("USE `{$name}`");
|
||||
if ($action === 'revoke') {
|
||||
$db->exec("UPDATE users SET user_auth_version = user_auth_version + 1 WHERE user_name = 'httpuser'");
|
||||
exit;
|
||||
}
|
||||
if ($action === 'token') {
|
||||
$id = $db->query("SELECT user_serial FROM users WHERE user_name = 'httpuser'")->fetchColumn();
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
|
||||
$stmt->execute([hash('sha256', $token), $id]);
|
||||
echo json_encode(['token' => $token]);
|
||||
}
|
||||
Reference in New Issue
Block a user