Files
pic/tests/conftest.py
roof 8650704316 feat: add authentication and authorization system
Backend:
- AuthManager (api/auth_manager.py): server-side user store with bcrypt
  password hashing, account lockout after 5 failed attempts (15 min),
  and atomic file writes
- AuthRoutes (api/auth_routes.py): Blueprint at /api/auth/* — login,
  logout, me, change-password, admin reset-password, list-users
- app.py: register auth_bp blueprint; add enforce_auth before_request
  hook (401 for unauthenticated, 403 for wrong role; only active when
  auth store has users so pre-auth tests remain green); instantiate
  AuthManager; update POST /api/peers to require password >= 10 chars
  and auto-provision email + calendar + files + auth accounts with full
  rollback on any failure; extend DELETE /api/peers to tear down all
  four service accounts; add /api/peer/dashboard and /api/peer/services
  peer-scoped routes; fix is_local_request to also trust the last
  X-Forwarded-For entry appended by the reverse proxy (Caddy)
- Role-based access: admin for /api/* (except /api/auth/* which is
  public and /api/peer/* which is peer-only)
- setup_cell.py: generate and print initial admin password, store in
  .admin_initial_password with 0600 permissions; cleaned up on first
  admin login

Frontend:
- AuthContext.jsx: React context with login/logout/me state and Axios
  interceptor for automatic 401 redirect
- PrivateRoute.jsx: route guard component
- Login.jsx: login page with error handling and must-change-password
  redirect
- AccountSettings.jsx: change-password form for any authenticated user
- PeerDashboard.jsx: peer-role landing page (IP, service list)
- MyServices.jsx: peer service links page
- App.jsx, Sidebar.jsx: AuthContext integration, logout button,
  PrivateRoute wrappers, peer-role routing
- Peers.jsx, WireGuard.jsx, api.js: auth-aware API calls

Tests: 100 new auth tests all pass (test_auth_manager, test_auth_routes,
test_route_protection, test_peer_provisioning). Fix pre-existing test
failures: update WireGuard test keys to valid 44-char base64 format
(test_wireguard_manager, test_peer_wg_integration), add password field
and service manager mocks to test_api_endpoints peer tests, add auth
helpers to conftest.py. Full suite: 845 passed, 0 failures.

Fixed: .admin_initial_password security cleanup on bootstrap, username
minimum length (3 chars enforced by USERNAME_RE regex)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:00:06 -04:00

189 lines
6.0 KiB
Python

"""
Shared pytest fixtures for the PIC test suite.
"""
import os
import sys
import json
import tempfile
import shutil
from unittest.mock import patch
import pytest
# Ensure api/ is on the path for all tests
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
# ── directory helpers ─────────────────────────────────────────────────────────
@pytest.fixture
def tmp_dir():
"""Temporary directory that is cleaned up after each test."""
d = tempfile.mkdtemp()
yield d
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture
def tmp_config_dir(tmp_dir):
"""Temporary config dir with the sub-directories expected by managers."""
for sub in ('api', 'caddy', 'dns', 'dhcp', 'ntp', 'wireguard'):
os.makedirs(os.path.join(tmp_dir, sub), exist_ok=True)
return tmp_dir
@pytest.fixture
def tmp_data_dir(tmp_dir):
"""Temporary data dir with the sub-directories expected by managers."""
for sub in ('dns', 'mail', 'calendar', 'files', 'wireguard'):
os.makedirs(os.path.join(tmp_dir, sub), exist_ok=True)
return tmp_dir
# ── auth helpers ──────────────────────────────────────────────────────────────
def create_test_users(auth_mgr):
"""Seed an AuthManager with the standard admin + peer test accounts.
Safe to call multiple times — AuthManager silently ignores duplicate
usernames, so calling this on an already-seeded store is a no-op.
Args:
auth_mgr: An AuthManager instance (real or mock).
Returns:
The same auth_mgr instance for convenience.
"""
auth_mgr.create_user('admin', 'AdminPass123!', 'admin')
auth_mgr.create_user('alice', 'AlicePass123!', 'peer')
return auth_mgr
def _do_login(client, username, password):
"""POST to /api/auth/login and return the response."""
return client.post(
'/api/auth/login',
data=json.dumps({'username': username, 'password': password}),
content_type='application/json',
)
def _make_auth_manager_at(base_path):
"""Create an AuthManager pointing at base_path/data and base_path/config."""
from auth_manager import AuthManager
data_dir = os.path.join(base_path, 'data')
config_dir = os.path.join(base_path, 'config')
os.makedirs(data_dir, exist_ok=True)
os.makedirs(config_dir, exist_ok=True)
return AuthManager(data_dir=data_dir, config_dir=config_dir)
# ── Flask client fixtures ─────────────────────────────────────────────────────
@pytest.fixture
def flask_client(tmp_dir):
"""Flask test client that is pre-authenticated as admin.
All existing tests that relied on the old unauthenticated flask_client
will continue to work because the before_request auth hook (when present)
checks the session — and this fixture establishes a valid admin session
before yielding.
When auth_routes is not yet registered (backend in progress), the login
POST simply returns a non-200 status; in that case the fixture still
yields the client so tests that do not need auth can still run.
"""
from app import app
auth_mgr = _make_auth_manager_at(tmp_dir)
create_test_users(auth_mgr)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
patches = [patch('app.auth_manager', auth_mgr)]
try:
import auth_routes
patches.append(
patch.object(auth_routes, 'auth_manager', auth_mgr, create=True)
)
except (ImportError, AttributeError):
pass
started = [p.start() for p in patches]
try:
with app.test_client() as client:
# Best-effort login; if auth routes are not registered yet the
# post simply 404s / 405s and tests that need auth will fail
# explicitly rather than mysteriously.
_do_login(client, 'admin', 'AdminPass123!')
yield client
finally:
for p in patches:
p.stop()
@pytest.fixture
def admin_headers(tmp_dir):
"""Authenticated admin Flask test client (alias kept for new auth tests)."""
from app import app
auth_mgr = _make_auth_manager_at(tmp_dir)
create_test_users(auth_mgr)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
patches = [patch('app.auth_manager', auth_mgr)]
try:
import auth_routes
patches.append(
patch.object(auth_routes, 'auth_manager', auth_mgr, create=True)
)
except (ImportError, AttributeError):
pass
started = [p.start() for p in patches]
try:
with app.test_client() as client:
r = _do_login(client, 'admin', 'AdminPass123!')
assert r.status_code == 200, (
f'admin_headers fixture: login failed {r.status_code} {r.data}'
)
yield client
finally:
for p in patches:
p.stop()
@pytest.fixture
def peer_headers(tmp_dir):
"""Authenticated peer (alice) Flask test client."""
from app import app
auth_mgr = _make_auth_manager_at(tmp_dir)
create_test_users(auth_mgr)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
patches = [patch('app.auth_manager', auth_mgr)]
try:
import auth_routes
patches.append(
patch.object(auth_routes, 'auth_manager', auth_mgr, create=True)
)
except (ImportError, AttributeError):
pass
started = [p.start() for p in patches]
try:
with app.test_client() as client:
r = _do_login(client, 'alice', 'AlicePass123!')
assert r.status_code == 200, (
f'peer_headers fixture: login failed {r.status_code} {r.data}'
)
yield client
finally:
for p in patches:
p.stop()