Add OPTIONS to API proxy, fix admin event listener accumulation, rename admin routes from /a/ to /admin/

- Add OPTIONS to the web API proxy route methods for CORS preflight support
- Fix event listener accumulation in admin/node-tags.js and admin/members.js
  using AbortController with cleanup functions returned to the SPA router.
  lit-html reuses DOM elements across re-renders, causing addEventListener
  calls to accumulate and fire multiple times per form submission.
- Rename admin routes from /a/ prefix to /admin/ for clarity
- Add debug logging for admin route access and OIDC role checks
- Move auth section in navbar after theme toggle
- Update tests and AGENTS.md accordingly
This commit is contained in:
Louis King
2026-04-29 00:45:45 +01:00
parent d1b6f0d0a7
commit 2af8b281ea
12 changed files with 110 additions and 87 deletions
+1 -1
View File
@@ -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`)
@@ -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 |
+8 -3
View File
@@ -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
+7
View File
@@ -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
+8 -8
View File
@@ -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
@@ -48,7 +48,7 @@ export async function render(container, params, router) {
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<a href="/admin/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconUsers('h-6 w-6')}
@@ -57,7 +57,7 @@ export async function render(container, params, router) {
<p>${t('admin.members_description')}</p>
</div>
</a>
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<a href="/admin/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconTag('h-6 w-6')}
@@ -78,7 +78,7 @@ export async function render(container, params, router) {
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li><a href="/admin/">${t('entities.admin')}</a></li>
<li>${t('entities.members')}</li>
</ul>
</div>
@@ -211,15 +211,18 @@ ${flashHtml}
</dialog>`, 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);
}
@@ -297,7 +297,7 @@ export async function render(container, params, router) {
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li><a href="/admin/">${t('entities.admin')}</a></li>
<li>${t('entities.tags')}</li>
</ul>
</div>
@@ -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);
}
@@ -128,7 +128,7 @@ export async function render(container, params, router) {
const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false)
? html`<div class="mt-3">
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
<a href="/admin/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
</div>`
: nothing;
+4 -4
View File
@@ -125,9 +125,6 @@
</ul>
</div>
<div class="navbar-end gap-1 pr-2">
{% if oidc_enabled %}
<div id="auth-section"></div>
{% endif %}
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" id="theme-toggle" />
@@ -136,6 +133,9 @@
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
{% if oidc_enabled %}
<div id="auth-section"></div>
{% endif %}
</div>
</div>
@@ -169,7 +169,7 @@
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/a/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/admin/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
</aside>
</footer>
+8 -8
View File
@@ -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]:
+6 -6
View File
@@ -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: