Files

286 lines
15 KiB
Python

"""Offline control-plane tests: no Docker, provider, LAN, or deployment access."""
import asyncio
import sqlite3
import time
import pytest
from fastapi.testclient import TestClient
from control.accounts import Accounts, password_matches, public_user
from control.app import Settings, create_app
PASSWORD = 'correct-horse-password'
SECRET = 'internal-secret-' + 'x' * 32
class FakeProvisioner:
def __init__(self):
self.ensured = []
self.removed = []
self.fail_ensure = False
self.fail_remove = False
self.healthy = True
async def ensure(self, user):
self.ensured.append(user['id'])
if self.fail_ensure:
raise RuntimeError('sensitive-provider-key-must-not-leak')
return {'container_name': user['container_name'], 'hermes_url': 'http://127.0.0.1:1234', 'hermes_api_key': 'private-hermes-key'}
async def ready(self, user):
return self.healthy
async def remove(self, user):
self.removed.append(user['id'])
if self.fail_remove:
raise RuntimeError('container ownership verification failed')
@pytest.fixture
def environment(tmp_path):
fake = FakeProvisioner()
settings = Settings(db_path=str(tmp_path / 'accounts.db'), admin_password=PASSWORD, service_secret=SECRET, allowed_origins=('https://testserver',), reconcile_interval=3600)
app = create_app(settings, fake)
with TestClient(app, base_url='https://testserver') as client:
yield client, app, fake, settings
def login(client, username='admin', password=PASSWORD):
result = client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': username, 'password': password})
assert result.status_code == 200, result.text
return result.json()
def auth(result):
return {'Authorization': 'Bearer ' + result['token']}
def wait_state(client, headers, user_id, status):
# Requests yield to the ASGI lifecycle loop; bounded wait, no network.
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
users = client.get('/api/admin/users', headers=headers).json()['users']
found = next((u for u in users if u['id'] == user_id), None)
if (status is None and found is None) or (found and found['status'] == status):
return found
raise AssertionError(f'Account did not reach {status}: {found}')
def create(client, headers, username='john-doe'):
response = client.post('/api/admin/users', headers=headers, json={'username': username, 'display_name': 'John Doe', 'password': PASSWORD})
assert response.status_code == 201, response.text
return response.json()['user']
def test_bootstrap_required(tmp_path):
app = create_app(Settings(db_path=str(tmp_path / 'missing.db'), admin_password=None), FakeProvisioner())
with pytest.raises(RuntimeError, match='CONTROL_ADMIN_PASSWORD'):
with TestClient(app):
pass
def test_password_sessions_cookie_schema(environment):
client, app, fake, settings = environment
assert client.get('/api/auth/me').status_code == 401
session = login(client)
assert set(session['user']) == {'id', 'username', 'display_name', 'role', 'status', 'container_name', 'error'}
assert len(session['user']['id']) == 32
assert session['expires_at'] > time.time()
cookie = client.cookies.get('hope_session')
assert cookie == session['token']
raw = app.state.accounts.get(session['user']['id'])
assert raw['password_hash'] != PASSWORD
assert password_matches(PASSWORD, raw['password_hash'])
row = app.state.accounts.db.execute('SELECT hash FROM sessions').fetchone()
assert row['hash'] != session['token']
assert client.get('/api/auth/me').status_code == 200
assert client.post('/api/auth/logout').status_code == 403
assert client.post('/api/auth/logout', headers={'Origin': 'https://evil.example'}).status_code == 403
assert client.post('/api/auth/logout', headers=auth(session)).status_code == 200
assert client.get('/api/auth/me', headers=auth(session)).status_code == 401
again = client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'admin', 'password': PASSWORD})
cookie_header = again.headers['set-cookie'].lower()
assert all(x in cookie_header for x in ('httponly', 'secure', 'samesite=strict'))
assert client.post('/api/auth/logout', headers={'Origin': 'https://testserver'}).status_code == 200
def test_create_auth_reset_and_validation(environment):
client, app, fake, settings = environment
admin = auth(login(client))
user = create(client, admin)
ready = wait_state(client, admin, user['id'], 'ready')
assert ready['container_name'] == f"hope-hermes-john-doe-{user['id'][:8]}"
assert client.post('/api/admin/users', headers=admin, json={'username': 'Bad/Name', 'display_name': 'Bad', 'password': PASSWORD}).status_code == 400
assert client.post('/api/admin/users', headers=admin, json={'username': 'john-doe', 'display_name': 'John', 'password': PASSWORD}).status_code == 409
ordinary = auth(login(client, 'john-doe'))
assert client.get('/api/admin/users', headers=ordinary).status_code == 403
assert client.get('/internal/users/' + user['id'], headers=ordinary).status_code == 401
assert client.post('/api/admin/users/' + user['id'] + '/password', headers=admin, json={'password': 'new-safe-password'}).status_code == 200
assert client.get('/api/auth/me', headers=ordinary).status_code == 401
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'john-doe', 'password': PASSWORD}).status_code == 401
login(client, 'john-doe', 'new-safe-password')
def test_last_admin_and_delete_revokes_sessions_rooms(environment):
client, app, fake, settings = environment
session = login(client)
admin = auth(session)
assert client.delete('/api/admin/users/' + session['user']['id'], headers=admin).status_code == 409
user = create(client, admin)
wait_state(client, admin, user['id'], 'ready')
ordinary = login(client, 'john-doe')
internal = {'X-Control-Secret': SECRET}
resolve = client.post('/internal/resolve-session', headers=internal, json={'token': ordinary['token']})
assert resolve.status_code == 200
assert resolve.json()['hermes_api_key'] == 'private-hermes-key'
assert 'password_hash' not in resolve.text
assert client.post('/internal/rooms', headers=internal, json={'user_id': user['id'], 'room_name': 'voice-test', 'session_token': ordinary['token']}).status_code == 201
assert client.get('/internal/rooms/voice-test', headers=internal).status_code == 200
fake.fail_remove = True
assert client.delete('/api/admin/users/' + user['id'], headers=admin).status_code == 202
assert client.get('/api/auth/me', headers=auth(ordinary)).status_code == 401
assert client.get('/internal/rooms/voice-test', headers=internal).status_code == 404
assert client.post('/internal/resolve-session', headers=internal, json={'token': ordinary['token']}).status_code == 401
wait_state(client, admin, user['id'], 'deleting')
fake.fail_remove = False
# Wait for failed first operation to finish before retry scheduling.
async def settled():
await asyncio.gather(*app.state.lifecycle.tasks.values())
client.portal.call(settled)
client.post('/api/admin/users/' + user['id'] + '/retry', headers=admin)
wait_state(client, admin, user['id'], None)
assert user['id'] in fake.removed
def test_failure_visible_and_restart_reconcile(tmp_path):
fake = FakeProvisioner()
fake.fail_ensure = True
settings = Settings(db_path=str(tmp_path / 'restart.db'), admin_password=PASSWORD, service_secret=SECRET, reconcile_interval=3600)
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
session = login(client)
header = auth(session)
user = wait_state(client, header, session['user']['id'], 'error')
assert 'sensitive' not in user['error']
fake.fail_ensure = False
settings.admin_password = None
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
wait_state(client, header, session['user']['id'], 'ready')
assert client.get('/api/auth/me', headers=header).status_code == 200
def test_expiry_throttle_and_bounded_input(environment):
client, app, fake, settings = environment
session = login(client)
with app.state.accounts.lock, app.state.accounts.db:
app.state.accounts.db.execute('UPDATE sessions SET expires_at=0')
assert client.get('/api/auth/me', headers=auth(session)).status_code == 401
client.cookies.clear()
assert client.post('/api/auth/login', json={'username': 'admin', 'password': 'p' * 257}).status_code == 400
assert client.post('/api/auth/login', content='x' * 9000, headers={'Content-Type': 'application/json'}).status_code == 413
assert client.post('/api/auth/login', content='{}', headers={'Content-Type': 'text/plain'}).status_code == 415
client.cookies.clear()
for _ in range(settings.login_limit):
result = client.post('/api/auth/login', json={'username': 'missing', 'password': PASSWORD})
assert result.status_code == 401
assert client.post('/api/auth/login', json={'username': 'missing', 'password': PASSWORD}).status_code == 429
def test_internal_fail_closed(environment):
client, app, fake, settings = environment
assert client.post('/internal/resolve-session', json={'token': 'x' * 32}).status_code == 401
assert client.get('/internal/users/' + 'a' * 32, headers={'X-Control-Secret': 'wrong'}).status_code == 401
settings.service_secret = None
assert client.get('/internal/rooms/voice-test', headers={'X-Control-Secret': SECRET}).status_code == 401
def test_room_session_binding_and_logout(environment):
client, app, fake, settings = environment
admin_session = login(client)
header = auth(admin_session)
wait_state(client, header, admin_session['user']['id'], 'ready')
user = create(client, header, 'alice')
wait_state(client, header, user['id'], 'ready')
session = login(client, 'alice')
internal = {'X-Control-Secret': SECRET}
mismatch = {'user_id': user['id'], 'room_name': 'wrong', 'session_token': admin_session['token']}
assert client.post('/internal/rooms', headers=internal, json=mismatch).status_code == 409
grant = {'user_id': user['id'], 'room_name': 'voice-alice', 'session_token': session['token']}
assert client.post('/internal/rooms', headers=internal, json=grant).status_code == 201
assert client.get('/internal/rooms/voice-alice', headers=internal).status_code == 200
assert client.post('/api/auth/logout', headers=auth(session)).status_code == 200
assert client.get('/internal/rooms/voice-alice', headers=internal).status_code == 404
def test_failed_removal_restart_retries(tmp_path):
path = str(tmp_path / 'removal.db')
store = Accounts(path)
store.create('admin', 'Administrator', 'admin', PASSWORD)
user = store.create('alice', 'Alice', 'user', PASSWORD)
store.begin_delete(user['id'])
store.close()
settings = Settings(db_path=path, admin_password=None, service_secret=SECRET, reconcile_interval=3600)
fake = FakeProvisioner()
fake.fail_remove = True
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
header = auth(login(client))
wait_state(client, header, user['id'], 'deleting')
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'alice', 'password': PASSWORD}).status_code == 401
fake.fail_remove = False
with TestClient(create_app(settings, fake), base_url='https://testserver') as client:
header = auth(login(client))
wait_state(client, header, user['id'], None)
assert fake.removed.count(user['id']) >= 2
def test_reset_between_password_verification_and_session_issue(environment, monkeypatch):
client, app, fake, settings = environment
original = app.state.accounts.issue_session
def reset_then_issue(user_id, ttl, expected_hash):
app.state.accounts.reset_password(user_id, 'changed-safe-password')
return original(user_id, ttl, expected_hash)
monkeypatch.setattr(app.state.accounts, 'issue_session', reset_then_issue)
response = client.post('/api/auth/login', json={'username': 'admin', 'password': PASSWORD})
assert response.status_code == 401
assert app.state.accounts.db.execute('SELECT count(*) FROM sessions').fetchone()[0] == 0
monkeypatch.setattr(app.state.accounts, 'issue_session', original)
login(client, password='changed-safe-password')
def test_successes_and_other_accounts_do_not_share_failure_budget(environment):
client, app, fake, settings = environment
for _ in range(settings.login_limit + 2):
login(client)
for _ in range(settings.login_limit):
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'missing', 'password': PASSWORD}).status_code == 401
assert client.post('/api/auth/login', headers={'Origin': 'https://testserver'}, json={'username': 'missing', 'password': PASSWORD}).status_code == 429
login(client) # Same IP, different account remains usable.
@pytest.mark.parametrize('peer,trusted', [('127.0.0.1', True), ('::1', True), ('203.0.113.10', False)])
def test_real_ip_only_trusted_from_loopback(tmp_path, peer, trusted):
settings = Settings(db_path=str(tmp_path / 'proxy.db'), admin_password=PASSWORD, login_limit=2)
with TestClient(create_app(settings, FakeProvisioner()), base_url='https://testserver', client=(peer, 1234)) as client:
body = {'username': 'missing', 'password': PASSWORD}
for _ in range(settings.login_limit):
assert client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.1'}, json=body).status_code == 401
assert client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.1'}, json=body).status_code == 429
other = client.post('/api/auth/login', headers={'X-Real-IP': '198.51.100.2', 'X-Forwarded-For': '198.51.100.3'}, json=body)
assert other.status_code == (401 if trusted else 429)
def test_database_delete_intent_persists(tmp_path):
db = str(tmp_path / 'durable.db')
store = Accounts(db)
admin = store.create('admin', 'Administrator', 'admin', PASSWORD)
user = store.create('alice', 'Alice', 'user', PASSWORD)
token, expiry = store.issue_session(user['id'], 60, user['password_hash'])
store.begin_delete(user['id'])
store.close()
store = Accounts(db)
assert store.get(user['id'])['desired'] == 'deleted'
assert store.resolve_session(token) is None
assert store.get(admin['id'])['role'] == 'admin'
store.close()