Skip to content

Commit eeca0ab

Browse files
committed
refactor: deduplicate E2E test helpers and export ROLE_LEVELS
- Unify _create_full_access_key, _create_scoped_key, and _create_api_key_with_scope into a single _create_api_key helper with optional scope and skip_on_error parameters - Extract _outline_api helper to eliminate repeated httpx.post boilerplate in _get_user_id, _get_user_role, _set_user_role - Rename _ROLE_LEVELS to ROLE_LEVELS and export from __init__.py https://claude.ai/code/session_0122umEU4tP9VMzCTrV6SdZN
1 parent aa06ada commit eeca0ab

File tree

4 files changed

+70
-108
lines changed

4 files changed

+70
-108
lines changed

src/mcp_outline/features/dynamic_tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
install_dynamic_tool_list,
1212
)
1313
from mcp_outline.features.dynamic_tools.introspect import (
14+
ROLE_LEVELS,
1415
build_role_blocked_map,
1516
build_tool_endpoint_map,
1617
)
1718

1819
__all__ = [
20+
"ROLE_LEVELS",
1921
"build_role_blocked_map",
2022
"build_tool_endpoint_map",
2123
"get_blocked_tools",

src/mcp_outline/features/dynamic_tools/introspect.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from mcp.server.fastmcp import FastMCP
1515

1616
# Outline workspace roles ordered by privilege level.
17-
_ROLE_LEVELS: dict[str, int] = {
17+
ROLE_LEVELS: dict[str, int] = {
1818
"viewer": 0,
1919
"member": 1,
2020
"admin": 2,
@@ -51,19 +51,19 @@ def build_role_blocked_map(
5151
(``collections.ts``, ``documents.ts``) and
5252
``AuthenticationHelper.ts``.
5353
"""
54-
blocked: dict[str, set[str]] = {r: set() for r in _ROLE_LEVELS}
54+
blocked: dict[str, set[str]] = {r: set() for r in ROLE_LEVELS}
5555
for name, tool in mcp._tool_manager._tools.items():
5656
min_role = (tool.meta or {}).get("min_role")
5757
if min_role is None:
5858
continue
59-
if min_role not in _ROLE_LEVELS:
59+
if min_role not in ROLE_LEVELS:
6060
raise ValueError(
6161
f"Tool '{name}' has invalid min_role "
6262
f"'{min_role}'; expected one of "
63-
f"{set(_ROLE_LEVELS)}"
63+
f"{set(ROLE_LEVELS)}"
6464
)
65-
min_level = _ROLE_LEVELS[min_role]
66-
for role, level in _ROLE_LEVELS.items():
65+
min_level = ROLE_LEVELS[min_role]
66+
for role, level in ROLE_LEVELS.items():
6767
if level < min_level:
6868
blocked[role].add(name)
6969
return {r: frozenset(s) for r, s in blocked.items()}

tests/e2e/conftest.py

Lines changed: 49 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ def _parse_set_cookies(response):
8383
return cookies
8484

8585

86+
def _outline_api(
87+
token: str,
88+
endpoint: str,
89+
json: dict | None = None,
90+
) -> httpx.Response:
91+
"""POST to an Outline API endpoint with Bearer auth."""
92+
return httpx.post(
93+
f"{OUTLINE_URL}/api/{endpoint}",
94+
headers={"Authorization": f"Bearer {token}"},
95+
json=json or {},
96+
timeout=30.0,
97+
)
98+
99+
86100
def _require_redirect(resp, step_description: str) -> str:
87101
"""Extract the ``Location`` header from a redirect response.
88102
@@ -215,17 +229,30 @@ def _login_once(
215229
return access_token
216230

217231

218-
def _create_full_access_key(access_token: str) -> str:
219-
"""Create a full-access API key and return its value."""
220-
resp = httpx.post(
221-
f"{OUTLINE_URL}/api/apiKeys.create",
222-
headers={
223-
"Authorization": f"Bearer {access_token}",
224-
},
225-
json={"name": "e2e-test"},
226-
timeout=30.0,
227-
)
228-
resp.raise_for_status()
232+
def _create_api_key(
233+
access_token: str,
234+
name: str = "e2e-test",
235+
scope: list | None = None,
236+
*,
237+
skip_on_error: bool = False,
238+
) -> str:
239+
"""Create an Outline API key and return its value.
240+
241+
Args:
242+
access_token: OIDC session token (Bearer).
243+
name: Key name shown in Outline Settings.
244+
scope: Scope array, or ``None`` for full access.
245+
skip_on_error: If ``True``, call ``pytest.skip``
246+
instead of raising on non-200 responses.
247+
"""
248+
json_body: dict = {"name": name}
249+
if scope is not None:
250+
json_body["scope"] = scope
251+
resp = _outline_api(access_token, "apiKeys.create", json_body)
252+
if resp.status_code != 200:
253+
if skip_on_error:
254+
pytest.skip(f"apiKeys.create returned {resp.status_code}")
255+
resp.raise_for_status()
229256
return resp.json()["data"]["value"]
230257

231258

@@ -237,19 +264,13 @@ def _login_and_create_api_key():
237264
token.
238265
"""
239266
token = _login("admin@example.com", "admin")
240-
key = _create_full_access_key(token)
267+
key = _create_api_key(token)
241268
return key, token
242269

243270

244271
def _get_user_id(access_token: str) -> str:
245272
"""Return the user ID for the given session token."""
246-
resp = httpx.post(
247-
f"{OUTLINE_URL}/api/auth.info",
248-
headers={
249-
"Authorization": f"Bearer {access_token}",
250-
},
251-
timeout=30.0,
252-
)
273+
resp = _outline_api(access_token, "auth.info")
253274
resp.raise_for_status()
254275
return resp.json()["data"]["user"]["id"]
255276

@@ -259,14 +280,7 @@ def _get_user_role(
259280
user_id: str,
260281
) -> str | None:
261282
"""Return the current role for *user_id* via ``users.info``."""
262-
resp = httpx.post(
263-
f"{OUTLINE_URL}/api/users.info",
264-
headers={
265-
"Authorization": f"Bearer {admin_token}",
266-
},
267-
json={"id": user_id},
268-
timeout=30.0,
269-
)
283+
resp = _outline_api(admin_token, "users.info", {"id": user_id})
270284
if resp.status_code != 200:
271285
return None
272286
return resp.json()["data"].get("role")
@@ -283,13 +297,10 @@ def _set_user_role(
283297
handles profile fields like name/avatar). Skips dependent
284298
tests if the endpoint is not available.
285299
"""
286-
resp = httpx.post(
287-
f"{OUTLINE_URL}/api/users.update_role",
288-
headers={
289-
"Authorization": f"Bearer {admin_token}",
290-
},
291-
json={"id": user_id, "role": role},
292-
timeout=30.0,
300+
resp = _outline_api(
301+
admin_token,
302+
"users.update_role",
303+
{"id": user_id, "role": role},
293304
)
294305
if resp.status_code != 200:
295306
pytest.skip(
@@ -451,7 +462,7 @@ def _viewer_credentials(outline_stack, _outline_credentials):
451462
user_id = _get_user_id(viewer_token)
452463

453464
# Create all keys while user is still a member
454-
full_key = _create_full_access_key(viewer_token)
465+
full_key = _create_api_key(viewer_token)
455466
scoped_keys = _create_viewer_scoped_keys(viewer_token)
456467

457468
# Now demote to viewer — keys remain valid
@@ -468,11 +479,7 @@ def _viewer_credentials(outline_stack, _outline_credentials):
468479
# Verify the viewer's API key still works after demotion.
469480
# auth.info requires no specific role, so this confirms
470481
# the key wasn't invalidated by the role change.
471-
resp = httpx.post(
472-
f"{OUTLINE_URL}/api/auth.info",
473-
headers={"Authorization": f"Bearer {full_key}"},
474-
timeout=30.0,
475-
)
482+
resp = _outline_api(full_key, "auth.info")
476483
if resp.status_code != 200:
477484
pytest.skip(
478485
f"Viewer API key invalid after role change "
@@ -492,42 +499,19 @@ def _create_viewer_scoped_keys(
492499
Returns a dict keyed by test name.
493500
"""
494501
return {
495-
"with_auth_info": _create_scoped_key(
502+
"with_auth_info": _create_api_key(
496503
access_token,
497504
"e2e-viewer-with-auth-info",
498505
["apiKeys.list", "auth.info", "documents:write"],
499506
),
500-
"without_auth_info": _create_scoped_key(
507+
"without_auth_info": _create_api_key(
501508
access_token,
502509
"e2e-viewer-no-auth-info",
503510
["apiKeys.list", "documents:write"],
504511
),
505512
}
506513

507514

508-
def _create_scoped_key(
509-
access_token: str,
510-
name: str,
511-
scope: list,
512-
) -> str:
513-
"""Create a scoped API key via the Outline admin API.
514-
515-
Like ``_create_api_key_with_scope`` in the test file but
516-
usable from conftest (no pytest.skip on failure — raises
517-
instead).
518-
"""
519-
resp = httpx.post(
520-
f"{OUTLINE_URL}/api/apiKeys.create",
521-
headers={
522-
"Authorization": f"Bearer {access_token}",
523-
},
524-
json={"name": name, "scope": scope},
525-
timeout=30.0,
526-
)
527-
resp.raise_for_status()
528-
return resp.json()["data"]["value"]
529-
530-
531515
@pytest.fixture(scope="session")
532516
def viewer_api_key(_viewer_credentials):
533517
"""Full-access API key for the viewer user."""

tests/e2e/test_dynamic_tool_list.py

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
build_tool_endpoint_map,
4747
)
4848

49+
from .conftest import _create_api_key
4950
from .helpers import OUTLINE_URL
5051

5152
HTTP_PORT = 3997
@@ -114,37 +115,6 @@ async def _list_tools_stdio(api_key: str) -> set[str]:
114115
return {t.name for t in result.tools}
115116

116117

117-
def _create_api_key_with_scope(
118-
access_token: str,
119-
name: str,
120-
scope: list,
121-
) -> str:
122-
"""Create a scoped API key via the Outline admin API.
123-
124-
Uses the OIDC *access_token* (session token) to call
125-
``apiKeys.create``. This endpoint requires a session
126-
token -- API keys cannot create other API keys.
127-
128-
Raises ``pytest.skip`` if the Outline instance does not
129-
support scoped API keys.
130-
131-
Returns the API key value string.
132-
"""
133-
resp = httpx.post(
134-
f"{OUTLINE_URL}/api/apiKeys.create",
135-
headers={"Authorization": f"Bearer {access_token}"},
136-
json={"name": name, "scope": scope},
137-
timeout=30.0,
138-
)
139-
if resp.status_code != 200:
140-
pytest.skip(
141-
"Outline does not support scoped API keys "
142-
f"(apiKeys.create returned {resp.status_code})"
143-
)
144-
145-
return resp.json()["data"]["value"]
146-
147-
148118
def _assert_tools(
149119
actual: set[str],
150120
expected: set[str],
@@ -287,7 +257,7 @@ async def test_route_scoped_read_only(
287257
Scope: explicit ``namespace.method`` entries for every
288258
read-only endpoint in the TOOL_ENDPOINT_MAP.
289259
"""
290-
key = _create_api_key_with_scope(
260+
key = _create_api_key(
291261
outline_access_token,
292262
"e2e-stdio-route-read-only",
293263
[
@@ -306,6 +276,7 @@ async def test_route_scoped_read_only(
306276
"documents.archived",
307277
"documents.deleted",
308278
],
279+
skip_on_error=True,
309280
)
310281

311282
expected = {
@@ -351,7 +322,7 @@ async def test_namespace_read_scope(
351322
route scope. ``collections.export_all`` likewise defaults
352323
to ``write`` and needs ``collections:write`` or a route scope.
353324
"""
354-
key = _create_api_key_with_scope(
325+
key = _create_api_key(
355326
outline_access_token,
356327
"e2e-stdio-namespace-read",
357328
[
@@ -360,6 +331,7 @@ async def test_namespace_read_scope(
360331
"collections:read",
361332
"comments:read",
362333
],
334+
skip_on_error=True,
363335
)
364336

365337
expected = {
@@ -393,10 +365,11 @@ async def test_namespace_write_documents_only(
393365
stay blocked (attachment tools now map to the ``attachments``
394366
namespace).
395367
"""
396-
key = _create_api_key_with_scope(
368+
key = _create_api_key(
397369
outline_access_token,
398370
"e2e-stdio-namespace-write-docs",
399371
["apiKeys.list", "documents:write"],
372+
skip_on_error=True,
400373
)
401374

402375
expected = {
@@ -442,7 +415,7 @@ async def test_mixed_namespace_and_route_scope(
442415
Attachment, export, comment, and other write tools are
443416
blocked.
444417
"""
445-
key = _create_api_key_with_scope(
418+
key = _create_api_key(
446419
outline_access_token,
447420
"e2e-stdio-mixed-scope",
448421
[
@@ -451,6 +424,7 @@ async def test_mixed_namespace_and_route_scope(
451424
"collections.create",
452425
"collections.list",
453426
],
427+
skip_on_error=True,
454428
)
455429

456430
expected = {
@@ -480,10 +454,11 @@ async def test_namespace_create_scope(
480454
matches methods whose ``methodToScope`` is ``create``
481455
(i.e. the ``create`` method itself).
482456
"""
483-
key = _create_api_key_with_scope(
457+
key = _create_api_key(
484458
outline_access_token,
485459
"e2e-stdio-namespace-create-docs",
486460
["apiKeys.list", "documents:create"],
461+
skip_on_error=True,
487462
)
488463

489464
expected = {
@@ -516,10 +491,11 @@ async def test_http_header_filters_tools(
516491
_assert_tools(admin_names, ALL_TOOLS, "http header admin key")
517492

518493
# Scoped key via header -> subset
519-
scoped_key = _create_api_key_with_scope(
494+
scoped_key = _create_api_key(
520495
outline_access_token,
521496
"e2e-http-header-scoped",
522497
["apiKeys.list", "documents:read"],
498+
skip_on_error=True,
523499
)
524500
scoped_names = await _list_tools_http(scoped_key)
525501
assert "read_document" in scoped_names

0 commit comments

Comments
 (0)