Codex's updates...

This commit is contained in:
2026-10-18 19:27:17 -04:00
parent a1643eb1b7
commit 02223083f6
338 changed files with 14757 additions and 7986 deletions
+127
View File
@@ -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)
+134
View File
@@ -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'], '&lt;b&gt;Jane &amp; club&lt;/b&gt;'), '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}`");
}
+33
View File
@@ -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]);
}