Files
2026-09-22 19:23:17 +02:00

179 lines
6.3 KiB
Python

import concurrent.futures
import json
import os
import sqlite3
import stat
import time
import pytest
from aim_webgui.auth.service import Auth, HASHER, digest
from aim_webgui.errors import WebError
from aim_webgui.config import Settings
@pytest.fixture
def settings(tmp_path):
state=tmp_path/'state';state.mkdir(mode=0o700)
return Settings(state_dir=state)
@pytest.fixture
def auth(settings):
auth=Auth(settings);auth.bootstrap();return auth
@pytest.fixture
def password(auth):return json.loads(auth.settings.credentials.read_text())['password']
NEW = 'Independent-test-passphrase-2026'
def test_bootstrap_secure_and_idempotent(auth, password):
assert stat.S_IMODE(auth.settings.credentials.stat().st_mode) == 0o600
assert stat.S_IMODE(auth.settings.database.stat().st_mode) == 0o600
assert len(password) >= 32
with auth.store.read() as db:
row = db.execute('SELECT * FROM users').fetchone()
assert row['password_hash'].startswith('$argon2id$')
assert HASHER.verify(row['password_hash'], password)
assert row['must_change_password'] == 1
auth.settings.credentials.unlink()
assert auth.bootstrap() is False
assert not auth.settings.credentials.exists()
assert len(auth.users()) == 1
def test_bootstrap_concurrent(settings):
with concurrent.futures.ThreadPoolExecutor(2) as pool:
results = list(pool.map(lambda _: Auth(settings).bootstrap(), range(2)))
assert sorted(results) == [False, True]
assert len(Auth(settings).users()) == 1
def test_missing_database_not_rebootstrapped(auth):
auth.settings.database.unlink()
with pytest.raises(ValueError, match='missing'):
auth.bootstrap()
def test_password_change_revokes_and_consumes(auth, password):
anon, _ = auth.new_session()
token, s = auth.login('admin', password, anon, 'test-peer')
assert s['must_change_password']
assert token != anon
assert auth.session(anon) is None
with auth.store.read() as db:
assert db.execute('SELECT token_hash FROM sessions').fetchone()[0] == digest(token)
assert token.encode() not in auth.settings.database.read_bytes()
auth.change_password(s['user_id'], password, NEW)
assert auth.session(token) is None
assert not auth.settings.credentials.exists()
assert password not in (auth.settings.state_dir / '.credentials.used').read_text()
anon, _ = auth.new_session()
_, active = auth.login('admin', NEW, anon, 'test-peer')
assert not active['must_change_password']
def test_last_admin_guard(auth):
for action in ('disable', 'demote'):
with pytest.raises(WebError, match='last enabled'):
auth.manage_user('admin', action)
auth.create_user('second-admin', NEW, 'admin')
auth.manage_user('admin', 'disable')
with pytest.raises(WebError):
auth.manage_user('second-admin', 'disable')
def test_role_authorization_rechecked(auth):
auth.create_user('viewer', NEW)
viewer = next(u for u in auth.users() if u['username'] == 'viewer')
with pytest.raises(WebError) as result:
auth.manage_user('admin', 'revoke', actor_id=viewer['id'])
assert result.value.status == 403
def test_disabled_and_reset_sessions(auth, password):
auth.create_user('reader', NEW)
reader = next(u for u in auth.users() if u['username'] == 'reader')
token, _ = auth.new_session(reader['id'])
auth.manage_user('reader', 'disable')
assert auth.session(token) is None
with pytest.raises(WebError, match='Invalid username'):
auth.login('reader', NEW, '', 'peer')
auth.manage_user('reader', 'enable')
token, _ = auth.new_session(reader['id'])
auth.manage_user('reader', 'reset-password', password=NEW + '-reset')
assert auth.session(token) is None
def test_expired_sessions(auth):
token, _ = auth.new_session()
with auth.store.transaction() as db:
db.execute('UPDATE sessions SET expires_at=?', (int(time.time()) - 1,))
assert auth.session(token) is None
def test_throttle(auth):
for _ in range(10):
with pytest.raises(WebError) as e:
auth.login('admin', 'bad', '', 'test-peer')
assert e.value.status == 401
with pytest.raises(WebError) as e:
auth.login('admin', 'bad', '', 'test-peer')
assert e.value.status == 429
def test_migration_backup_and_future_schema(auth, tmp_path):
auth.store.migrate()
out = tmp_path / 'auth-backup.sqlite3'
auth.store.backup(out)
assert stat.S_IMODE(out.stat().st_mode) == 0o600
db = sqlite3.connect(out)
assert db.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
assert db.execute('SELECT COUNT(*) FROM users').fetchone()[0] == 1
db.close()
with auth.store.transaction() as db:
db.execute('PRAGMA user_version=999')
with pytest.raises(ValueError, match='newer'):
auth.store.migrate()
def test_credentials_symlink_rejected(settings, tmp_path):
other = tmp_path / 'other'
other.write_text('do not overwrite')
settings.credentials.symlink_to(other)
with pytest.raises(ValueError):
Auth(settings).bootstrap()
assert other.read_text() == 'do not overwrite'
def test_short_window_does_not_clear_long_window(auth, monkeypatch):
import aim_webgui.auth.service as module
monkeypatch.setattr(module.time, 'time', lambda:10000)
auth.throttle([('long',1)],window=300)
monkeypatch.setattr(module.time, 'time', lambda:10070)
auth.throttle([('short',1)],window=60)
with pytest.raises(WebError) as e:
auth.throttle([('long',1)],window=300)
assert e.value.status == 429
def test_bootstrap_repairs_missing_post_commit_marker_without_reset(auth, password):
marker = auth.settings.state_dir / '.initialized'
marker.unlink()
before = auth.settings.credentials.read_bytes()
assert auth.bootstrap() is False
assert marker.is_file()
assert auth.settings.credentials.read_bytes() == before
auth.settings.database.unlink()
with pytest.raises(ValueError, match='missing'):
auth.bootstrap()
def test_local_recovery_can_reenable_out_of_band_disabled_admin(auth):
with auth.store.transaction() as db:
db.execute("UPDATE users SET enabled=0 WHERE username='admin'")
with pytest.raises(ValueError, match='administrator'):
auth.store.check()
auth.store.check(require_admin=False)
auth.manage_user('admin', 'enable')
auth.store.check()