""" Scenarios 20, 21: Peer role access scoping. Tests cover: - Peer is blocked from admin-only routes (config, wireguard, peer list) - Peer can access /api/peer/dashboard and /api/peer/services - Dashboard response shape (name, online, transfer_rx, transfer_tx, service_urls) - Services response shape (wireguard, email, caldav, files sections) - Peer can change their own password and use the new credential - Peer cannot call admin/reset-password """ import pytest from helpers.api_client import PicAPIClient # --------------------------------------------------------------------------- # Admin-only routes must be blocked for peer role # --------------------------------------------------------------------------- def test_peer_cannot_access_config(peer_client): r = peer_client.get('/api/config') assert r.status_code == 403, ( f"Peer should be blocked from /api/config with 403, got {r.status_code}" ) def test_peer_cannot_access_wireguard_settings(peer_client): r = peer_client.get('/api/wireguard/status') assert r.status_code == 403, ( f"Peer should be blocked from /api/wireguard/status with 403, got {r.status_code}" ) def test_peer_cannot_list_peers(peer_client): r = peer_client.get('/api/peers') assert r.status_code == 403, ( f"Peer should be blocked from GET /api/peers with 403, got {r.status_code}" ) # --------------------------------------------------------------------------- # Peer-accessible routes # --------------------------------------------------------------------------- def test_peer_can_access_own_dashboard(peer_client): r = peer_client.get('/api/peer/dashboard') assert r.status_code == 200, ( f"Peer should be able to GET /api/peer/dashboard, got {r.status_code}: {r.text}" ) def test_peer_dashboard_has_expected_fields(peer_client): r = peer_client.get('/api/peer/dashboard') assert r.status_code == 200 data = r.json() missing = [f for f in ('name', 'online', 'transfer_rx', 'transfer_tx', 'allowed_ips', 'service_urls') if f not in data] assert not missing, ( f"Dashboard response missing fields {missing}. Got keys: {list(data.keys())}" ) def test_peer_dashboard_no_stale_field_names(peer_client): """Verify renamed fields are gone — old names cause silent UI blanks.""" r = peer_client.get('/api/peer/dashboard') assert r.status_code == 200 data = r.json() stale = [f for f in ('peer_name', 'rx_bytes', 'tx_bytes') if f in data] assert not stale, ( f"Dashboard response still has stale fields {stale} — " "PeerDashboard.jsx reads name/transfer_rx/transfer_tx" ) def test_peer_can_access_own_services(peer_client): r = peer_client.get('/api/peer/services') assert r.status_code == 200, ( f"Peer should be able to GET /api/peer/services, got {r.status_code}: {r.text}" ) def test_peer_services_has_expected_sections(peer_client): r = peer_client.get('/api/peer/services') assert r.status_code == 200 data = r.json() missing = [k for k in ('wireguard', 'email', 'caldav', 'files') if k not in data] assert not missing, ( f"Services response missing sections {missing}. Got keys: {list(data.keys())}" ) def test_peer_services_no_stale_keys(peer_client): """Verify renamed keys are gone — old names cause silent UI blanks.""" r = peer_client.get('/api/peer/services') assert r.status_code == 200 data = r.json() assert 'webdav' not in data, ( "'webdav' still present at top level — MyServices.jsx reads 'files'" ) def test_peer_services_email_structure(peer_client): """Email section must use nested smtp/imap objects and email.address.""" r = peer_client.get('/api/peer/services') assert r.status_code == 200 email = r.json().get('email', {}) assert 'address' in email, f"email.address missing; email keys: {list(email)}" assert 'smtp' in email and isinstance(email['smtp'], dict), \ f"email.smtp must be a dict; got: {email.get('smtp')}" assert 'imap' in email and isinstance(email['imap'], dict), \ f"email.imap must be a dict; got: {email.get('imap')}" assert 'host' in email['smtp'], "email.smtp.host missing" assert 'host' in email['imap'], "email.imap.host missing" assert 'imap_host' not in email, "'imap_host' still flat — should be email.imap.host" assert 'smtp_host' not in email, "'smtp_host' still flat — should be email.smtp.host" def test_peer_services_caldav_url_uses_calendar_domain(peer_client): """CalDAV URL must be calendar.dev, not radicale.dev:5232.""" r = peer_client.get('/api/peer/services') assert r.status_code == 200 url = r.json().get('caldav', {}).get('url', '') assert 'radicale' not in url, \ f"CalDAV URL must not contain 'radicale' — no radicale.dev DNS record; got: {url}" assert ':5232' not in url, \ f"CalDAV URL exposes port 5232 — use Caddy-proxied URL; got: {url}" def test_peer_services_wireguard_dns_not_vpn_gateway(peer_client): """WireGuard DNS must be the CoreDNS IP, not the VPN gateway 10.0.0.1.""" r = peer_client.get('/api/peer/services') assert r.status_code == 200 dns = r.json().get('wireguard', {}).get('dns', '') assert dns != '10.0.0.1', ( "wireguard.dns is 10.0.0.1 (WireGuard VPN gateway) — " "DNS queries to 10.0.0.1 fail because the VPN server doesn't run a DNS resolver; " "must be the CoreDNS container IP" ) # --------------------------------------------------------------------------- # Auth management — scoping # --------------------------------------------------------------------------- def test_peer_cannot_access_auth_users(peer_client): r = peer_client.get('/api/auth/users') assert r.status_code == 403, ( f"Peer should be blocked from GET /api/auth/users with 403, got {r.status_code}" ) def test_peer_cannot_reset_other_password(peer_client): r = peer_client.post('/api/auth/admin/reset-password', json={'username': 'admin', 'new_password': 'HackedPass1!'}) assert r.status_code == 403, ( f"Peer should be blocked from admin/reset-password with 403, got {r.status_code}" ) def test_peer_can_change_own_password(make_peer, api_base): """ A peer can change their own password via POST /api/auth/change-password. After the change the new password must work for login. """ peer = make_peer('e2etest-change-pass', password='OldPass123!') client = PicAPIClient(api_base) client.login(peer['name'], 'OldPass123!') r = client.post('/api/auth/change-password', json={'old_password': 'OldPass123!', 'new_password': 'NewPass456!'}) assert r.status_code == 200, ( f"change-password should return 200, got {r.status_code}: {r.text}" ) # Verify new password works new_client = PicAPIClient(api_base) new_client.login(peer['name'], 'NewPass456!') me = new_client.me() assert me.get('username') == peer['name'], ( f"Login with new password failed — me() returned: {me}" )