diff --git a/AGENTS.md b/AGENTS.md index bd7dd22..66b651d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -643,7 +643,7 @@ Key variables: - `OIDC_DISCOVERY_URL` - OIDC discovery URL (required if OIDC_ENABLED=true) - `OIDC_REDIRECT_URI` - Explicit callback URL (overrides auto-derivation) - `OIDC_POST_LOGOUT_REDIRECT_URI` - Post-logout redirect URI (must match IdP sign-out URIs, falls back to `OIDC_REDIRECT_URI` base) -- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically for direnv compatibility. +- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically for direnv compatibility. When using LogTo as the OIDC provider, include `roles` in `OIDC_SCOPES` (e.g., `"openid email profile roles"`) to enable role-based admin access. - `OIDC_ROLES_CLAIM` - ID token claim for roles (default: `roles`) - `OIDC_ADMIN_ROLE` - Role value for admin access (default: `admin`) - `OIDC_MEMBER_ROLE` - Role value for member access (default: `member`) diff --git a/docs/plans/20260428-1300-oidc-oauth-support/plan.md b/docs/plans/20260428-1300-oidc-oauth-support/plan.md index d5426a4..d11a30a 100644 --- a/docs/plans/20260428-1300-oidc-oauth-support/plan.md +++ b/docs/plans/20260428-1300-oidc-oauth-support/plan.md @@ -93,10 +93,10 @@ Role assignment flow: ``` Browser Web App (:8080) OIDC Provider (IdP) | | | - | GET /a/node-tags | | + | GET /admin/node-tags | | |------------------------------>| | | | No session | - | 302 /auth/login?next=/a/node-tags | + | 302 /auth/login?next=/admin/node-tags | |<------------------------------| | | | | | GET /auth/login | | @@ -116,10 +116,10 @@ Browser Web App (:8080) OIDC Provider (IdP | |<------------------------------| | | Extract userinfo + roles | | | Set session cookie | - | 302 → /a/node-tags | | + | 302 → /admin/node-tags | | |<------------------------------| | | | | - | GET /a/node-tags | | + | GET /admin/node-tags | | |------------------------------>| | | | Session valid, role=admin | | | Proxy to API with api_key | diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index c29cd71..eea1641 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -450,7 +450,7 @@ def create_app( # --- API Proxy --- @app.api_route( "/api/{path:path}", - methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], tags=["API Proxy"], ) async def api_proxy(request: Request, path: str) -> Response: @@ -910,8 +910,8 @@ def create_app( async def spa_catchall(request: Request, path: str = "") -> Response: """Serve the SPA shell for all non-API routes.""" # Admin route protection when OIDC is enabled - if path.startswith("a") and ( - path == "a" or path == "a/" or path.startswith("a/") + if path.startswith("admin") and ( + path == "admin" or path == "admin/" or path.startswith("admin/") ): if request.app.state.oidc_enabled: user = get_session_user(request) @@ -919,6 +919,11 @@ def create_app( from starlette.responses import RedirectResponse return RedirectResponse(url=f"/auth/login?next=/{path}") + logger.debug( + "Admin route access: path=%s, user=%s", + path, + user.get("name"), + ) templates_inst: Jinja2Templates = request.app.state.templates features = request.app.state.features diff --git a/src/meshcore_hub/web/oidc.py b/src/meshcore_hub/web/oidc.py index 4d9c514..44447b2 100644 --- a/src/meshcore_hub/web/oidc.py +++ b/src/meshcore_hub/web/oidc.py @@ -54,6 +54,13 @@ def get_user_roles( roles = [roles] is_admin = admin_role in roles is_member = member_role in roles + logger.info( + "OIDC roles check: roles_claim=%s, raw_roles=%s, is_member=%s, is_admin=%s", + roles_claim, + roles, + is_member, + is_admin, + ) return is_member, is_admin diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index 02dfb12..335d17f 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -89,10 +89,10 @@ if (features.pages !== false) { // Admin routes (only register when OIDC disabled or user is admin) if (!config.oidc_enabled || config.is_admin) { - router.addRoute('/a', pageHandler(pages.adminIndex)); - router.addRoute('/a/', pageHandler(pages.adminIndex)); - router.addRoute('/a/node-tags', pageHandler(pages.adminNodeTags)); - router.addRoute('/a/members', pageHandler(pages.adminMembers)); + router.addRoute('/admin', pageHandler(pages.adminIndex)); + router.addRoute('/admin/', pageHandler(pages.adminIndex)); + router.addRoute('/admin/node-tags', pageHandler(pages.adminNodeTags)); + router.addRoute('/admin/members', pageHandler(pages.adminMembers)); } // 404 handler @@ -147,10 +147,10 @@ function updatePageTitle(pathname) { const networkName = config.network_name || 'MeshCore Network'; const titles = { '/': networkName, - '/a': composePageTitle('entities.admin'), - '/a/': composePageTitle('entities.admin'), - '/a/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`, - '/a/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`, + '/admin': composePageTitle('entities.admin'), + '/admin/': composePageTitle('entities.admin'), + '/admin/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`, + '/admin/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`, }; // Add feature-dependent titles diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js index 125ff4c..5150cc4 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js @@ -48,7 +48,7 @@ export async function render(container, params, router) {
- +

${iconUsers('h-6 w-6')} @@ -57,7 +57,7 @@ export async function render(container, params, router) {

${t('admin.members_description')}

- +

${iconTag('h-6 w-6')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js index f65ede3..4b5b1a5 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js @@ -78,7 +78,7 @@ export async function render(container, params, router) { @@ -211,15 +211,18 @@ ${flashHtml} `, container); let activeDeleteId = ''; + const ac = new AbortController(); + const signal = ac.signal; + const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal }); // Add Member - container.querySelector('#btn-add-member').addEventListener('click', () => { + on(container.querySelector('#btn-add-member'), 'click', () => { const form = container.querySelector('#add-member-form'); form.reset(); container.querySelector('#addModal').showModal(); }); - container.querySelector('#addCancel').addEventListener('click', () => { + on(container.querySelector('#addCancel'), 'click', () => { container.querySelector('#addModal').close(); }); @@ -237,16 +240,16 @@ ${flashHtml} try { await apiPost('/api/v1/members', body); container.querySelector('#addModal').close(); - router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') }))); + router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') }))); } catch (err) { container.querySelector('#addModal').close(); - router.navigate('/a/members?error=' + encodeURIComponent(err.message)); + router.navigate('/admin/members?error=' + encodeURIComponent(err.message)); } - }); + }, { signal }); // Edit Member container.querySelectorAll('.btn-edit').forEach(btn => { - btn.addEventListener('click', () => { + on(btn, 'click', () => { const row = btn.closest('tr'); container.querySelector('#edit_id').value = row.dataset.memberId; container.querySelector('#edit_member_id').value = row.dataset.memberMemberId; @@ -258,7 +261,7 @@ ${flashHtml} }); }); - container.querySelector('#editCancel').addEventListener('click', () => { + on(container.querySelector('#editCancel'), 'click', () => { container.querySelector('#editModal').close(); }); @@ -277,16 +280,16 @@ ${flashHtml} try { await apiPut('/api/v1/members/' + encodeURIComponent(id), body); container.querySelector('#editModal').close(); - router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') }))); + router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') }))); } catch (err) { container.querySelector('#editModal').close(); - router.navigate('/a/members?error=' + encodeURIComponent(err.message)); + router.navigate('/admin/members?error=' + encodeURIComponent(err.message)); } - }); + }, { signal }); // Delete Member container.querySelectorAll('.btn-delete').forEach(btn => { - btn.addEventListener('click', () => { + on(btn, 'click', () => { const row = btn.closest('tr'); activeDeleteId = row.dataset.memberId; const memberName = row.dataset.memberName; @@ -299,21 +302,23 @@ ${flashHtml} }); }); - container.querySelector('#deleteCancel').addEventListener('click', () => { + on(container.querySelector('#deleteCancel'), 'click', () => { container.querySelector('#deleteModal').close(); }); - container.querySelector('#deleteConfirm').addEventListener('click', async () => { + on(container.querySelector('#deleteConfirm'), 'click', async () => { try { await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId)); container.querySelector('#deleteModal').close(); - router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') }))); + router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') }))); } catch (err) { container.querySelector('#deleteModal').close(); - router.navigate('/a/members?error=' + encodeURIComponent(err.message)); + router.navigate('/admin/members?error=' + encodeURIComponent(err.message)); } }); + return () => ac.abort(); + } catch (e) { litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js index 79b78ee..290547e 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js @@ -297,7 +297,7 @@ export async function render(container, params, router) { @@ -328,21 +328,25 @@ ${flashHtml} ${contentHtml}`, container); + const ac = new AbortController(); + const signal = ac.signal; + const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal }); + // Event: node selector change const nodeSelector = container.querySelector('#node-selector'); - nodeSelector.addEventListener('change', () => { + on(nodeSelector, 'change', () => { const pk = nodeSelector.value; if (pk) { - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(pk)); } else { - router.navigate('/a/node-tags'); + router.navigate('/admin/node-tags'); } }); - container.querySelector('#load-tags-btn').addEventListener('click', () => { + on(container.querySelector('#load-tags-btn'), 'click', () => { const pk = nodeSelector.value; if (pk) { - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(pk)); } }); @@ -361,15 +365,15 @@ ${contentHtml}`, container); await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', { key, value, value_type, }); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') }))); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') }))); } catch (err) { - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } - }); + }, { signal }); // Edit button handlers container.querySelectorAll('.btn-edit').forEach(btn => { - btn.addEventListener('click', () => { + on(btn, 'click', () => { const row = btn.closest('tr'); activeTagKey = row.dataset.tagKey; container.querySelector('#editKeyDisplay').value = activeTagKey; @@ -379,7 +383,7 @@ ${contentHtml}`, container); }); }); - container.querySelector('#editCancel').addEventListener('click', () => { + on(container.querySelector('#editCancel'), 'click', () => { container.querySelector('#editModal').close(); }); @@ -393,16 +397,16 @@ ${contentHtml}`, container); value, value_type, }); container.querySelector('#editModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') }))); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') }))); } catch (err) { container.querySelector('#editModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } - }); + }, { signal }); // Move button handlers container.querySelectorAll('.btn-move').forEach(btn => { - btn.addEventListener('click', () => { + on(btn, 'click', () => { const row = btn.closest('tr'); activeTagKey = row.dataset.tagKey; container.querySelector('#moveKeyDisplay').value = activeTagKey; @@ -411,7 +415,7 @@ ${contentHtml}`, container); }); }); - container.querySelector('#moveCancel').addEventListener('click', () => { + on(container.querySelector('#moveCancel'), 'click', () => { container.querySelector('#moveModal').close(); }); @@ -425,16 +429,16 @@ ${contentHtml}`, container); new_public_key: newPublicKey, }); container.querySelector('#moveModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') }))); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') }))); } catch (err) { container.querySelector('#moveModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } - }); + }, { signal }); // Delete button handlers container.querySelectorAll('.btn-delete').forEach(btn => { - btn.addEventListener('click', () => { + on(btn, 'click', () => { const row = btn.closest('tr'); activeTagKey = row.dataset.tagKey; const confirmMsg = t('common.delete_entity_confirm', { @@ -446,30 +450,30 @@ ${contentHtml}`, container); }); }); - container.querySelector('#deleteCancel').addEventListener('click', () => { + on(container.querySelector('#deleteCancel'), 'click', () => { container.querySelector('#deleteModal').close(); }); - container.querySelector('#deleteConfirm').addEventListener('click', async () => { + on(container.querySelector('#deleteConfirm'), 'click', async () => { try { await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey)); container.querySelector('#deleteModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') }))); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') }))); } catch (err) { container.querySelector('#deleteModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } }); // Copy All button const copyAllBtn = container.querySelector('#btn-copy-all'); if (copyAllBtn) { - copyAllBtn.addEventListener('click', () => { + on(copyAllBtn, 'click', () => { container.querySelector('#copyAllDestination').selectedIndex = 0; container.querySelector('#copyAllModal').showModal(); }); - container.querySelector('#copyAllCancel').addEventListener('click', () => { + on(container.querySelector('#copyAllCancel'), 'click', () => { container.querySelector('#copyAllModal').close(); }); @@ -482,38 +486,40 @@ ${contentHtml}`, container); const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey)); container.querySelector('#copyAllModal').close(); const msg = t('admin_node_tags.copied_entities', { copied: result.copied, skipped: result.skipped }); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg)); } catch (err) { container.querySelector('#copyAllModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } - }); + }, { signal }); } // Delete All button const deleteAllBtn = container.querySelector('#btn-delete-all'); if (deleteAllBtn) { - deleteAllBtn.addEventListener('click', () => { + on(deleteAllBtn, 'click', () => { container.querySelector('#deleteAllModal').showModal(); }); - container.querySelector('#deleteAllCancel').addEventListener('click', () => { + on(container.querySelector('#deleteAllCancel'), 'click', () => { container.querySelector('#deleteAllModal').close(); }); - container.querySelector('#deleteAllConfirm').addEventListener('click', async () => { + on(container.querySelector('#deleteAllConfirm'), 'click', async () => { try { await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags'); container.querySelector('#deleteAllModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() }))); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() }))); } catch (err) { container.querySelector('#deleteAllModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); + router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } }); } } + return () => ac.abort(); + } catch (e) { litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index 8be63d2..abac38f 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -128,7 +128,7 @@ export async function render(container, params, router) { const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false) ? html`` : nothing; diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 6f7cd74..ca6b97e 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -125,9 +125,6 @@

@@ -169,7 +169,7 @@ {{ t('links.youtube') }} {% endif %}

-

{% if oidc_enabled %}{{ t('entities.admin') }} | {% endif %}{{ t('footer.powered_by') }} MeshCore Hub {{ version }}

+

{% if oidc_enabled %}{{ t('entities.admin') }} | {% endif %}{{ t('footer.powered_by') }} MeshCore Hub {{ version }}

diff --git a/tests/test_web/test_admin.py b/tests/test_web/test_admin.py index 8b3f778..36d23cb 100644 --- a/tests/test_web/test_admin.py +++ b/tests/test_web/test_admin.py @@ -94,13 +94,13 @@ class TestAdminHome: def test_admin_home_returns_spa_shell(self, admin_client): """Test admin home page returns the SPA shell.""" - response = admin_client.get("/a/") + response = admin_client.get("/admin/") assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text def test_admin_home_config_is_admin(self, admin_client): """Test admin config shows is_admin: true.""" - response = admin_client.get("/a/") + response = admin_client.get("/admin/") config = _extract_config(response.text) assert config["is_admin"] is True assert config["oidc_enabled"] is True @@ -114,7 +114,7 @@ class TestAdminHome: The SPA catch-all serves the shell for all routes. Client-side code checks oidc_enabled/is_admin to show/hide admin UI. """ - response = admin_client_disabled.get("/a/") + response = admin_client_disabled.get("/admin/") assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text @@ -124,14 +124,14 @@ class TestAdminNodeTags: def test_node_tags_page_returns_spa_shell(self, admin_client): """Test node tags page returns the SPA shell.""" - response = admin_client.get("/a/node-tags") + response = admin_client.get("/admin/node-tags") assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text def test_node_tags_page_with_public_key(self, admin_client): """Test node tags page with public_key param returns SPA shell.""" response = admin_client.get( - "/a/node-tags?public_key=abc123def456abc123def456abc123de", + "/admin/node-tags?public_key=abc123def456abc123def456abc123de", ) assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text @@ -141,7 +141,7 @@ class TestAdminNodeTags: admin_client_disabled, ): """Test node tags page returns SPA shell even when admin is disabled.""" - response = admin_client_disabled.get("/a/node-tags") + response = admin_client_disabled.get("/admin/node-tags") assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text @@ -153,14 +153,14 @@ class TestAdminFooterLink: """Test that admin link appears in footer when OIDC is enabled.""" response = admin_client.get("/") assert response.status_code == 200 - assert 'href="/a/"' in response.text + assert 'href="/admin/"' in response.text assert "Admin" in response.text def test_admin_link_hidden_when_oidc_disabled(self, admin_client_disabled): """Test that admin link does not appear in footer when OIDC disabled.""" response = admin_client_disabled.get("/") assert response.status_code == 200 - assert 'href="/a/"' not in response.text + assert 'href="/admin/"' not in response.text def _extract_config(text: str) -> dict[str, Any]: diff --git a/tests/test_web/test_oidc.py b/tests/test_web/test_oidc.py index 7f31feb..e90b7a8 100644 --- a/tests/test_web/test_oidc.py +++ b/tests/test_web/test_oidc.py @@ -54,7 +54,7 @@ class TestAuthLogin: url="https://idp.example.com/authorize?state=abc" ) response = client_with_oidc.get( - "/auth/login?next=/a/node-tags", follow_redirects=False + "/auth/login?next=/admin/node-tags", follow_redirects=False ) assert response.status_code == 307 assert "idp.example.com" in response.headers["location"] @@ -161,7 +161,7 @@ class TestAdminRouteProtection: def test_no_session_redirects_to_login(self, client_with_oidc: TestClient) -> None: """Test admin route redirects to /auth/login without session.""" - response = client_with_oidc.get("/a/", follow_redirects=False) + response = client_with_oidc.get("/admin/", follow_redirects=False) assert response.status_code == 307 assert "/auth/login" in response.headers["location"] @@ -169,7 +169,7 @@ class TestAdminRouteProtection: self, client_with_oidc_member_session: TestClient ) -> None: """Test member session gets SPA shell (client-side shows access denied).""" - response = client_with_oidc_member_session.get("/a/") + response = client_with_oidc_member_session.get("/admin/") assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text config = _extract_config(response.text) @@ -180,7 +180,7 @@ class TestAdminRouteProtection: self, client_with_oidc_admin_session: TestClient ) -> None: """Test admin session gets SPA shell with admin config.""" - response = client_with_oidc_admin_session.get("/a/") + response = client_with_oidc_admin_session.get("/admin/") assert response.status_code == 200 config = _extract_config(response.text) assert config["oidc_enabled"] is True @@ -239,7 +239,7 @@ class TestBackwardCompatibility: self, client: TestClient ) -> None: """Test admin routes serve SPA shell when OIDC disabled (no redirect).""" - response = client.get("/a/") + response = client.get("/admin/") assert response.status_code == 200 assert "window.__APP_CONFIG__" in response.text @@ -247,7 +247,7 @@ class TestBackwardCompatibility: """Test footer has no admin link when OIDC disabled.""" response = client.get("/") assert response.status_code == 200 - assert 'href="/a/"' not in response.text + assert 'href="/admin/"' not in response.text class TestConfigInjection: