feat: provision private Hermes assistants for authenticated Hope accounts
This commit is contained in:
1 parent
da65da68b3
commit
2aedea84b5
33 files changed
+3063
-1743
No files matched your search
@@ -0,0 +1,285 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user