From d0644befba554ef45e6a69b134369120bdcdccbe Mon Sep 17 00:00:00 2001 From: witbrock Date: Tue, 18 Aug 2026 12:55:17 +0200 Subject: [PATCH] Add provider-neutral task external-resource actions (JVNAUTOSCI-2647) --- .../integrations/google/gmail_service.py | 80 +++++ .../gmail_task_external_resource_actions.py | 136 ++++++++ src/backend/server/routes/task_routes.py | 88 +++++- .../task_external_resource_action_service.py | 292 ++++++++++++++++++ .../static/js/components/taskPanel.js | 48 +++ .../web/von_interface/static/styles.css | 27 ++ .../test_gmail_message_navigation_target.py | 71 +++++ ...st_gmail_task_external_resource_actions.py | 133 ++++++++ ...t_task_external_resource_action_service.py | 175 +++++++++++ .../test_task_routes_detail_endpoints.py | 123 ++++++++ tests/frontend/taskPanelConceptLinks.test.js | 110 +++++++ 11 files changed, 1267 insertions(+), 16 deletions(-) create mode 100644 src/backend/integrations/google/gmail_task_external_resource_actions.py create mode 100644 src/backend/services/task_external_resource_action_service.py create mode 100644 tests/backend/test_gmail_message_navigation_target.py create mode 100644 tests/backend/test_gmail_task_external_resource_actions.py create mode 100644 tests/backend/test_task_external_resource_action_service.py diff --git a/src/backend/integrations/google/gmail_service.py b/src/backend/integrations/google/gmail_service.py index 1df29f84f..e16ab87cb 100644 --- a/src/backend/integrations/google/gmail_service.py +++ b/src/backend/integrations/google/gmail_service.py @@ -893,6 +893,86 @@ def get_message( return request.execute() or {} +def resolve_message_thread_navigation_target( + profile_id: str, + message_id: str, + *, + profiles: Optional[Dict[str, GmailProfile]] = None, + audit_context: Optional[Mapping[str, object]] = None, +) -> dict[str, str]: + """Read the smallest Gmail state needed to open a message's thread. + + Gmail's REST message identifier is a stable API handle, not a browser + permalink. Resolve its parent thread with a ``minimal`` partial response, + then return the authenticated mailbox address needed by a browser caller to + select the right Gmail account. This helper performs no label or message + mutation and never returns message content, headers, or credentials. + """ + + cleaned_message_id = message_id.strip() if isinstance(message_id, str) else "" + if not cleaned_message_id: + raise ValueError("message_id is required to resolve a Gmail thread link") + + profile = get_profile(profile_id, profiles) + _log_gmail_audit( + "resolve_message_thread_navigation_target", + profile_id=profile.profile_id, + audit_context=audit_context, + message_id=cleaned_message_id, + ) + service = get_service(profile.profile_id, profiles) + message = ( + service.users() + .messages() + .get( + userId=profile.user_id, + id=cleaned_message_id, + format="minimal", + fields="threadId", + ) + .execute() + or {} + ) + thread_id = message.get("threadId") if isinstance(message, Mapping) else None + if not isinstance(thread_id, str) or not thread_id.strip(): + raise RuntimeError("Gmail did not return a thread ID for the message") + + authorised_email: str | None = None + if callable(_get_agent_gmail_token_status): + try: + status = _get_agent_gmail_token_status(profile.profile_id) + candidate = getattr(status, "authorised_email", None) + if isinstance(candidate, str) and candidate.strip(): + authorised_email = candidate.strip() + except Exception as exc: # provider identity fallback is bounded + logger.debug( + "[gmail_service] token-status identity lookup failed for %s: %s", + profile.profile_id, + type(exc).__name__, + ) + + if authorised_email is None: + profile_payload = ( + service.users().getProfile(userId=profile.user_id).execute() or {} + ) + candidate = ( + profile_payload.get("emailAddress") + if isinstance(profile_payload, Mapping) + else None + ) + if isinstance(candidate, str) and candidate.strip(): + authorised_email = candidate.strip() + if authorised_email is None: + raise RuntimeError("Gmail did not return an authorised mailbox address") + + return { + "profile_id": profile.profile_id, + "message_id": cleaned_message_id, + "thread_id": thread_id.strip(), + "authorised_email": authorised_email, + } + + def get_attachment( profile_id: str, message_id: str, diff --git a/src/backend/integrations/google/gmail_task_external_resource_actions.py b/src/backend/integrations/google/gmail_task_external_resource_actions.py new file mode 100644 index 000000000..e71ea8cc2 --- /dev/null +++ b/src/backend/integrations/google/gmail_task_external_resource_actions.py @@ -0,0 +1,136 @@ +"""Navigation-only external-resource actions for Gmail-backed Von tasks. + +The provider deliberately knows only the durable Gmail source evidence emitted +by the mail-ingestion workflow. It does not interpret task prose, perform +Gmail mutations, or let a task-supplied profile bypass the normal actor-scoped +Gmail authority boundary. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from urllib.parse import quote + +from src.backend.services.task_external_resource_action_service import ( + OPEN_RESOURCE_ACTION_KIND, + TaskExternalResourceActionAccessError, + TaskExternalResourceActionResolutionError, +) + +GMAIL_OPEN_SOURCE_ACTION_ID = "gmail.open_source" +_GMAIL_SOURCE_SYSTEM = "gmail" +_MAX_SOURCE_FIELD_LENGTH = 512 + + +def _clean_source_field(value: Any) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned or len(cleaned) > _MAX_SOURCE_FIELD_LENGTH: + return None + return cleaned + + +def _parse_gmail_source_evidence(task: Mapping[str, Any]) -> dict[str, str] | None: + """Read the exact, delimited Gmail source fields from a task's evidence. + + Evidence is a small legacy text field, not a general serialisation format. + Requiring the exact key names avoids accidentally promoting prose such as a + copied message subject into an external resource identifier. + """ + + evidence = task.get("evidence") + if not isinstance(evidence, str) or not evidence.strip(): + return None + + fields: dict[str, str] = {} + for part in evidence.split(";"): + key, separator, value = part.partition("=") + key = key.strip() + cleaned_value = _clean_source_field(value) + if not separator or not key or cleaned_value is None or key in fields: + return None + fields[key] = cleaned_value + + if fields.get("source_system") != _GMAIL_SOURCE_SYSTEM: + return None + source_profile = fields.get("source_profile") + source_item_id = fields.get("source_item_id") + if source_profile is None or source_item_id is None: + return None + return { + "source_system": _GMAIL_SOURCE_SYSTEM, + "source_profile": source_profile, + "source_item_id": source_item_id, + } + + +def list_actions(task: Mapping[str, Any]) -> list[dict[str, str]]: + """List the one safe action available for a Gmail-ingestion review task.""" + + source = _parse_gmail_source_evidence(task) + if source is None: + return [] + return [ + { + "action_id": GMAIL_OPEN_SOURCE_ACTION_ID, + "kind": OPEN_RESOURCE_ACTION_KIND, + "label": "Open source in Gmail", + "source_system": _GMAIL_SOURCE_SYSTEM, + "resource_type": "gmail_thread", + } + ] + + +def resolve_action(task: Mapping[str, Any], action_id: str) -> str | None: + """Resolve the advertised Gmail action through trusted profile authority.""" + + if action_id != GMAIL_OPEN_SOURCE_ACTION_ID: + return None + source = _parse_gmail_source_evidence(task) + if source is None: + return None + + from src.backend.integrations.google import gmail_service + from src.backend.services.gmail_profile_invocation_authority_service import ( + GmailInvocationAuthorityError, + authorise_gmail_profile_for_invocation, + ) + + try: + authority = authorise_gmail_profile_for_invocation(source["source_profile"]) + except GmailInvocationAuthorityError as exc: + raise TaskExternalResourceActionAccessError( + reason_code=exc.reason_code, + safe_message=exc.safe_message, + ) from exc + + try: + target = gmail_service.resolve_message_thread_navigation_target( + profile_id=authority.profile_id, + message_id=source["source_item_id"], + audit_context={ + "namespace": authority.audit_namespace, + "source": "task_external_resource_action", + "action": GMAIL_OPEN_SOURCE_ACTION_ID, + }, + ) + except Exception as exc: # safe provider error boundary + raise TaskExternalResourceActionResolutionError( + reason_code="gmail_source_navigation_unavailable", + safe_message="The Gmail source could not be opened right now.", + ) from exc + + return ( + "https://mail.google.com/mail/?authuser=" + f"{quote(target['authorised_email'], safe='')}" + f"#all/{quote(target['thread_id'], safe='')}" + ) + + +__all__ = [ + "GMAIL_OPEN_SOURCE_ACTION_ID", + "list_actions", + "resolve_action", +] diff --git a/src/backend/server/routes/task_routes.py b/src/backend/server/routes/task_routes.py index 97d8820c6..e28855ecd 100644 --- a/src/backend/server/routes/task_routes.py +++ b/src/backend/server/routes/task_routes.py @@ -4,35 +4,42 @@ Tasks are stored as Vontology concepts. """ -from datetime import datetime, timezone import logging import time +from datetime import datetime, timezone from typing import Any -from flask import Blueprint, request, jsonify, session +from flask import Blueprint, jsonify, redirect, request, session, url_for from flask.typing import ResponseReturnValue +from ...services.task_external_resource_action_service import ( + TaskExternalResourceActionAccessError, + TaskExternalResourceActionNotFoundError, + TaskExternalResourceActionResolutionError, + list_task_external_resource_actions, + resolve_task_external_resource_action, +) from ...services.task_management_service import ( + InvalidTaskDataError, + TaskManagementError, + TaskNotFoundError, + add_task_attachment, + add_task_comment, + apply_bulk_task_visibility, + backfill_jira_migration_bulk_task_collections, create_task, + delete_task, get_task, + get_task_history, get_task_taxonomy, - update_task_fields, get_tasks_for_conversation, + link_tasks, + list_task_attachments, + list_task_comments, list_tasks_with_visibility, search_tasks, - apply_bulk_task_visibility, - backfill_jira_migration_bulk_task_collections, - delete_task, - add_task_comment, - list_task_comments, - add_task_attachment, - list_task_attachments, - get_task_history, - link_tasks, unlink_tasks, - TaskNotFoundError, - InvalidTaskDataError, - TaskManagementError, + update_task_fields, ) logger = logging.getLogger(__name__) @@ -262,7 +269,19 @@ def get_task_taxonomy_route() -> ResponseReturnValue: def get_task_route(task_concept_id: str) -> ResponseReturnValue: """Get a task by concept_id.""" try: - result = get_task(task_concept_id) + result = dict(get_task(task_concept_id)) + actions = list_task_external_resource_actions(result) + result["external_resource_actions"] = [ + { + **action, + "href": url_for( + "tasks.open_task_external_resource_action_route", + task_concept_id=task_concept_id, + action_id=action["action_id"], + ), + } + for action in actions + ] return jsonify(result), 200 except TaskNotFoundError as e: @@ -272,6 +291,43 @@ def get_task_route(task_concept_id: str) -> ResponseReturnValue: return jsonify({"error": "Internal server error"}), 500 +@task_bp.route( + "//external-resource-actions/", + methods=["GET"], +) +def open_task_external_resource_action_route( + task_concept_id: str, + action_id: str, +) -> ResponseReturnValue: + """Resolve one provider-neutral task navigation action.""" + + try: + task = get_task(task_concept_id) + target = resolve_task_external_resource_action(task, action_id) + return redirect(target, code=302) + except TaskNotFoundError as exc: + return jsonify({"error": str(exc)}), 404 + except TaskExternalResourceActionNotFoundError as exc: + return jsonify( + {"error": exc.safe_message, "reason_code": exc.reason_code} + ), 404 + except TaskExternalResourceActionAccessError as exc: + return jsonify( + {"error": exc.safe_message, "reason_code": exc.reason_code} + ), 403 + except TaskExternalResourceActionResolutionError as exc: + return jsonify( + {"error": exc.safe_message, "reason_code": exc.reason_code} + ), 502 + except Exception as exc: # preserve the existing route error boundary + logger.error( + "Unexpected error resolving task external-resource action %s: %s", + action_id, + exc, + ) + return jsonify({"error": "Internal server error"}), 500 + + @task_bp.route("/", methods=["PATCH"]) def update_task_route(task_concept_id: str) -> ResponseReturnValue: """Update a task. diff --git a/src/backend/services/task_external_resource_action_service.py b/src/backend/services/task_external_resource_action_service.py new file mode 100644 index 000000000..a19cf068b --- /dev/null +++ b/src/backend/services/task_external_resource_action_service.py @@ -0,0 +1,292 @@ +"""Provider-neutral external resource actions for tasks. + +Tasks may refer to canonical resources in systems outside Von. This service +projects those references into a small, provider-neutral action collection for +the task UI, while leaving provider-specific lookup and URL construction next +to the corresponding integration code. + +Only read-only ``open_resource`` navigation is supported here. Effectful +actions (for example submit, approve, or reschedule) require their own +authority and effect contracts rather than being disguised as links. +""" + +from __future__ import annotations + +import importlib +import logging +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import ModuleType +from typing import Any +from urllib.parse import urlsplit + +logger = logging.getLogger(__name__) + +OPEN_RESOURCE_ACTION_KIND = "open_resource" + +_ACTION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_PROVIDER_MODULE_PATHS = ( + "src.backend.integrations.google.gmail_task_external_resource_actions", +) + + +@dataclass(frozen=True) +class TaskExternalResourceActionError(RuntimeError): + """Safe typed failure at the generic external-resource boundary.""" + + reason_code: str + safe_message: str + + def __str__(self) -> str: + return self.safe_message + + +class TaskExternalResourceActionNotFoundError(TaskExternalResourceActionError): + """The requested action is not available for this task.""" + + +class TaskExternalResourceActionAccessError(TaskExternalResourceActionError): + """The current actor may not resolve the requested external resource.""" + + +class TaskExternalResourceActionResolutionError(TaskExternalResourceActionError): + """The provider could not resolve the requested external resource.""" + + +def _load_providers() -> tuple[ModuleType, ...]: + """Load the deliberately small provider registry. + + Adding a provider is a registration change. Provider-specific task + interpretation, API access, and URL construction do not belong here. + """ + + providers: list[ModuleType] = [] + for path in _PROVIDER_MODULE_PATHS: + try: + providers.append(importlib.import_module(path)) + except Exception as exc: # noqa: BLE001 - isolate optional providers + logger.warning( + "Task external-resource provider %s could not be loaded: %s", + path, + type(exc).__name__, + ) + return tuple(providers) + + +def _normalise_optional_text(value: Any, *, max_length: int) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned or len(cleaned) > max_length: + return None + return cleaned + + +def _normalise_action(raw_action: Any) -> dict[str, str] | None: + if not isinstance(raw_action, Mapping): + return None + + action_id = _normalise_optional_text(raw_action.get("action_id"), max_length=128) + if action_id is None or _ACTION_ID_PATTERN.fullmatch(action_id) is None: + return None + + kind = _normalise_optional_text(raw_action.get("kind"), max_length=64) + if kind != OPEN_RESOURCE_ACTION_KIND: + return None + + label = _normalise_optional_text(raw_action.get("label"), max_length=100) + if label is None: + return None + + action = { + "action_id": action_id, + "kind": kind, + "label": label, + } + for key, max_length in ( + ("source_system", 64), + ("resource_type", 64), + ("description", 240), + ): + value = _normalise_optional_text(raw_action.get(key), max_length=max_length) + if value is not None: + action[key] = value + return action + + +def _provider_actions( + provider: ModuleType, + task: Mapping[str, Any], +) -> list[dict[str, str]]: + list_actions = getattr(provider, "list_actions", None) + if not callable(list_actions): + logger.error( + "Task external-resource provider %s has no list_actions function", + provider.__name__, + ) + return [] + + try: + raw_actions = list_actions(task) + except Exception as exc: # noqa: BLE001 - isolate provider projection + logger.warning( + "Task external-resource provider %s could not list actions: %s", + provider.__name__, + type(exc).__name__, + ) + return [] + + if not isinstance(raw_actions, Sequence) or isinstance( + raw_actions, (str, bytes, bytearray) + ): + logger.error( + "Task external-resource provider %s returned a non-list action value", + provider.__name__, + ) + return [] + + actions: list[dict[str, str]] = [] + for raw_action in raw_actions: + action = _normalise_action(raw_action) + if action is None: + logger.warning( + "Task external-resource provider %s returned an invalid action", + provider.__name__, + ) + continue + actions.append(action) + return actions + + +def list_task_external_resource_actions( + task: Mapping[str, Any], + *, + providers: Sequence[ModuleType] | None = None, +) -> list[dict[str, str]]: + """Return validated, provider-neutral navigation actions for one task.""" + + selected_providers = ( + tuple(providers) if providers is not None else _load_providers() + ) + actions: list[dict[str, str]] = [] + seen_ids: set[str] = set() + for provider in selected_providers: + for action in _provider_actions(provider, task): + action_id = action["action_id"] + if action_id in seen_ids: + logger.warning( + "Ignoring duplicate task external-resource action id %s", + action_id, + ) + continue + seen_ids.add(action_id) + actions.append(action) + return actions + + +def _validate_resolved_url(value: Any) -> str: + if not isinstance(value, str): + raise TaskExternalResourceActionResolutionError( + reason_code="external_resource_target_invalid", + safe_message=( + "The external resource did not resolve to a valid web address." + ), + ) + cleaned = value.strip() + if not cleaned or len(cleaned) > 4096: + raise TaskExternalResourceActionResolutionError( + reason_code="external_resource_target_invalid", + safe_message=( + "The external resource did not resolve to a valid web address." + ), + ) + parsed = urlsplit(cleaned) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + ): + raise TaskExternalResourceActionResolutionError( + reason_code="external_resource_target_invalid", + safe_message="The external resource did not resolve to a safe web address.", + ) + return cleaned + + +def resolve_task_external_resource_action( + task: Mapping[str, Any], + action_id: str, + *, + providers: Sequence[ModuleType] | None = None, +) -> str: + """Resolve one advertised task action to a safe HTTPS target.""" + + cleaned_action_id = _normalise_optional_text(action_id, max_length=128) + if ( + cleaned_action_id is None + or _ACTION_ID_PATTERN.fullmatch(cleaned_action_id) is None + ): + raise TaskExternalResourceActionNotFoundError( + reason_code="external_resource_action_not_found", + safe_message="This external resource action is not available for the task.", + ) + + selected_providers = ( + tuple(providers) if providers is not None else _load_providers() + ) + for provider in selected_providers: + advertised_ids = { + action["action_id"] for action in _provider_actions(provider, task) + } + if cleaned_action_id not in advertised_ids: + continue + + resolve_action = getattr(provider, "resolve_action", None) + if not callable(resolve_action): + raise TaskExternalResourceActionResolutionError( + reason_code="external_resource_provider_invalid", + safe_message="The external resource provider is not available.", + ) + try: + target = resolve_action(task, cleaned_action_id) + except ( + TaskExternalResourceActionAccessError, + TaskExternalResourceActionResolutionError, + ): + raise + except Exception as exc: + logger.warning( + "Task external-resource provider %s could not resolve action %s: %s", + provider.__name__, + cleaned_action_id, + type(exc).__name__, + ) + raise TaskExternalResourceActionResolutionError( + reason_code="external_resource_resolution_failed", + safe_message="The external resource could not be opened right now.", + ) from exc + + if target is None: + raise TaskExternalResourceActionResolutionError( + reason_code="external_resource_resolution_failed", + safe_message="The external resource could not be opened right now.", + ) + return _validate_resolved_url(target) + + raise TaskExternalResourceActionNotFoundError( + reason_code="external_resource_action_not_found", + safe_message="This external resource action is not available for the task.", + ) + + +__all__ = [ + "OPEN_RESOURCE_ACTION_KIND", + "TaskExternalResourceActionAccessError", + "TaskExternalResourceActionError", + "TaskExternalResourceActionNotFoundError", + "TaskExternalResourceActionResolutionError", + "list_task_external_resource_actions", + "resolve_task_external_resource_action", +] diff --git a/src/frontend/web/von_interface/static/js/components/taskPanel.js b/src/frontend/web/von_interface/static/js/components/taskPanel.js index e3abeca07..44933b378 100644 --- a/src/frontend/web/von_interface/static/js/components/taskPanel.js +++ b/src/frontend/web/von_interface/static/js/components/taskPanel.js @@ -52,6 +52,7 @@ const TASK_GROUP_STORAGE_KEY_PREFIX = 'von_task_group_filter_v1'; const TASK_LIST_LIMIT = '500'; const GLOBAL_TASK_PAGE_SIZE = 50; const TASK_PANEL_LOAD_TELEMETRY_SCHEMA_VERSION = 'task_panel_load_telemetry.v1'; +const TASK_EXTERNAL_RESOURCE_ACTION_HREF_PATTERN = /^\/api\/tasks\/[A-Za-z0-9%_-]+\/external-resource-actions\/[A-Za-z0-9._%-]+$/; // Constants const TASK_STATUS_OPTIONS = [ @@ -1990,6 +1991,52 @@ function renderTaskTimeline(detailState, task) { `; } +function isSafeTaskExternalResourceActionHref(href) { + if (typeof href !== 'string' || !TASK_EXTERNAL_RESOURCE_ACTION_HREF_PATTERN.test(href)) { + return false; + } + try { + const segments = href.split('/'); + return [segments[3], segments[5]].every((segment) => { + const decoded = decodeURIComponent(segment); + return decoded && decoded !== '.' && decoded !== '..' && !/[\\/]/.test(decoded); + }); + } catch (_error) { + return false; + } +} + +function getTaskExternalResourceActions(task) { + const rawActions = Array.isArray(task?.external_resource_actions) + ? task.external_resource_actions + : []; + return rawActions.filter((action) => { + if (!action || typeof action !== 'object') return false; + if (action.kind !== 'open_resource') return false; + return typeof action.label === 'string' + && action.label.trim() + && isSafeTaskExternalResourceActionHref(action.href); + }); +} + +function renderTaskExternalResourceActions(task) { + const actions = getTaskExternalResourceActions(task); + if (actions.length === 0) return ''; + return ` +
+
External resources
+
+ ${actions.map((action) => ` + ${escapeHtml(action.label.trim())} + `).join('')} +
+
+ `; +} + function renderTaskInspector(task, detailState) { const detailTask = detailState?.task || task; const taskId = getTaskId(detailTask); @@ -2042,6 +2089,7 @@ function renderTaskInspector(task, detailState) {
Title
${escapeHtml(detailTask.title || 'Untitled task')}
Type
${taskTypes.length > 0 ? taskTypes.map((item) => `${escapeHtml(item.label || item.concept_id || 'Typed')}`).join('') : 'Unspecified'}
Source
${sourceSummary ? `${escapeHtml(sourceSummary.label)}` : 'Unspecified'}
+ ${renderTaskExternalResourceActions(detailTask)}
Priority
${escapeHtml(getPriorityInfo(detailTask.priority).label)}
Owner
${renderTaskConceptValue(detailTask.assignee_concept_id)}
Report to
${renderTaskConceptValue(detailTask.report_to_concept_id)}
diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index c48cf2c4e..00a895d37 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -3491,6 +3491,33 @@ body { line-height: 1.5; } +.task-external-resource-action { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 5px 11px; + border: 1px solid #0f766e; + border-radius: 8px; + background: #f0fdfa; + color: #0f766e; + font-size: 0.78rem; + font-weight: 700; + line-height: 1.25; + text-decoration: none; +} + +.task-external-resource-action:hover, +.task-external-resource-action:focus-visible { + background: #ccfbf1; + color: #115e59; + text-decoration: none; +} + +.task-external-resource-action:focus-visible { + outline: 2px solid #0f766e; + outline-offset: 2px; +} + .task-inspector-concept.task-concept-link { appearance: none; border: 0; diff --git a/tests/backend/test_gmail_message_navigation_target.py b/tests/backend/test_gmail_message_navigation_target.py new file mode 100644 index 000000000..6f720addd --- /dev/null +++ b/tests/backend/test_gmail_message_navigation_target.py @@ -0,0 +1,71 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from src.backend.integrations.google import gmail_service + + +def _gmail_service_with_thread(thread_id: str) -> MagicMock: + service = MagicMock() + message_get = service.users.return_value.messages.return_value.get + message_get.return_value.execute.return_value = {"threadId": thread_id} + return service + + +def test_navigation_target_uses_minimal_message_read_and_token_identity( + monkeypatch, +) -> None: + profile = gmail_service.GmailProfile( + profile_id="research-gmail", + token_path="unused", + ) + service = _gmail_service_with_thread("thread-123") + monkeypatch.setattr(gmail_service, "get_profile", lambda *_args, **_kwargs: profile) + monkeypatch.setattr(gmail_service, "get_service", lambda *_args, **_kwargs: service) + monkeypatch.setattr( + gmail_service, + "_get_agent_gmail_token_status", + lambda _profile_id: SimpleNamespace(authorised_email="person@example.test"), + ) + + target = gmail_service.resolve_message_thread_navigation_target( + "research-gmail", + "message-123", + audit_context={"source": "test"}, + ) + + assert target == { + "profile_id": "research-gmail", + "message_id": "message-123", + "thread_id": "thread-123", + "authorised_email": "person@example.test", + } + service.users.return_value.messages.return_value.get.assert_called_once_with( + userId="me", + id="message-123", + format="minimal", + fields="threadId", + ) + service.users.return_value.getProfile.assert_not_called() + + +def test_resolve_message_thread_navigation_target_falls_back_to_gmail_profile_identity( + monkeypatch, +) -> None: + profile = gmail_service.GmailProfile( + profile_id="research-gmail", + token_path="unused", + ) + service = _gmail_service_with_thread("thread-123") + service.users.return_value.getProfile.return_value.execute.return_value = { + "emailAddress": "fallback@example.test" + } + monkeypatch.setattr(gmail_service, "get_profile", lambda *_args, **_kwargs: profile) + monkeypatch.setattr(gmail_service, "get_service", lambda *_args, **_kwargs: service) + monkeypatch.setattr(gmail_service, "_get_agent_gmail_token_status", None) + + target = gmail_service.resolve_message_thread_navigation_target( + "research-gmail", "message-123" + ) + + assert target["authorised_email"] == "fallback@example.test" + service.users.return_value.getProfile.assert_called_once_with(userId="me") diff --git a/tests/backend/test_gmail_task_external_resource_actions.py b/tests/backend/test_gmail_task_external_resource_actions.py new file mode 100644 index 000000000..24cde9298 --- /dev/null +++ b/tests/backend/test_gmail_task_external_resource_actions.py @@ -0,0 +1,133 @@ +from types import SimpleNamespace + +import pytest + +from src.backend.integrations.google import ( + gmail_task_external_resource_actions as provider, +) +from src.backend.services.task_external_resource_action_service import ( + TaskExternalResourceActionAccessError, +) + + +def _gmail_task() -> dict[str, str]: + return { + "evidence": ( + "source_system=gmail; source_profile=research-gmail; " + "source_item_id=19f257e5c6caaba" + ) + } + + +def test_list_actions_projects_only_exact_gmail_source_evidence() -> None: + assert provider.list_actions(_gmail_task()) == [ + { + "action_id": "gmail.open_source", + "kind": "open_resource", + "label": "Open source in Gmail", + "source_system": "gmail", + "resource_type": "gmail_thread", + } + ] + + assert ( + provider.list_actions( + {"evidence": "source_system=gmail; source_profile=research-gmail"} + ) + == [] + ) + assert ( + provider.list_actions( + { + "evidence": ( + "source_system=mail; source_profile=research-gmail; " + "source_item_id=19f257e5c6caaba" + ) + } + ) + == [] + ) + assert ( + provider.list_actions( + { + "evidence": ( + "source_system=gmail; source_profile=research-gmail; " + "source_item_id=one; source_item_id=two" + ) + } + ) + == [] + ) + + +def test_resolve_action_uses_authorised_profile_and_encodes_thread_target( + monkeypatch, +) -> None: + from src.backend.integrations.google import gmail_service + from src.backend.services import ( + gmail_profile_invocation_authority_service as authority_service, + ) + + observed: dict[str, object] = {} + monkeypatch.setattr( + authority_service, + "authorise_gmail_profile_for_invocation", + lambda profile: SimpleNamespace( + profile_id=f"canonical-{profile}", + audit_namespace="actor:namespace", + ), + ) + + def fake_target(**kwargs): + observed.update(kwargs) + return { + "authorised_email": "Research+Mail@example.test", + "thread_id": "thread/one", + } + + monkeypatch.setattr( + gmail_service, + "resolve_message_thread_navigation_target", + fake_target, + ) + + assert provider.resolve_action(_gmail_task(), "gmail.open_source") == ( + "https://mail.google.com/mail/?authuser=" + "Research%2BMail%40example.test#all/thread%2Fone" + ) + assert observed == { + "profile_id": "canonical-research-gmail", + "message_id": "19f257e5c6caaba", + "audit_context": { + "namespace": "actor:namespace", + "source": "task_external_resource_action", + "action": "gmail.open_source", + }, + } + assert provider.resolve_action(_gmail_task(), "gmail.delete_source") is None + + +def test_resolve_action_converts_gmail_authority_denial_to_generic_access_error( + monkeypatch, +) -> None: + from src.backend.services import ( + gmail_profile_invocation_authority_service as authority_service, + ) + + def deny(_profile): + raise authority_service.GmailInvocationAuthorityError( + reason_code="gmail_profile_not_authorised", + safe_message="The requested Gmail profile is not authorised.", + ) + + monkeypatch.setattr( + authority_service, + "authorise_gmail_profile_for_invocation", + deny, + ) + + with pytest.raises(TaskExternalResourceActionAccessError) as error: + provider.resolve_action(_gmail_task(), "gmail.open_source") + + assert error.value.reason_code == "gmail_profile_not_authorised" + assert str(error.value) == "The requested Gmail profile is not authorised." diff --git a/tests/backend/test_task_external_resource_action_service.py b/tests/backend/test_task_external_resource_action_service.py new file mode 100644 index 000000000..f6bde4f05 --- /dev/null +++ b/tests/backend/test_task_external_resource_action_service.py @@ -0,0 +1,175 @@ +"""Provider-neutral task external-resource action service coverage.""" + +from __future__ import annotations + +from types import ModuleType + +import pytest + +from src.backend.services.task_external_resource_action_service import ( + TaskExternalResourceActionAccessError, + TaskExternalResourceActionNotFoundError, + TaskExternalResourceActionResolutionError, + list_task_external_resource_actions, + resolve_task_external_resource_action, +) + + +def _provider( + name: str, + actions: object, + *, + resolved_target: object = "https://example.test/resource", +) -> ModuleType: + provider = ModuleType(name) + + def list_actions(_task): + if isinstance(actions, BaseException): + raise actions + return actions + + def resolve_action(_task, _action_id): + if isinstance(resolved_target, BaseException): + raise resolved_target + return resolved_target + + provider.list_actions = list_actions + provider.resolve_action = resolve_action + return provider + + +def _open_action(action_id: str, label: str) -> dict[str, str]: + return { + "action_id": action_id, + "kind": "open_resource", + "label": label, + } + + +def test_list_actions_combines_multiple_provider_actions() -> None: + actions = list_task_external_resource_actions( + {"task_concept_id": "#V#task_1"}, + providers=( + _provider("provider_one", [_open_action("one.open", "Open one")]), + _provider("provider_two", [_open_action("two.open", "Open two")]), + ), + ) + + assert actions == [ + _open_action("one.open", "Open one"), + _open_action("two.open", "Open two"), + ] + + +def test_list_actions_filters_invalid_and_duplicate_provider_actions() -> None: + actions = list_task_external_resource_actions( + {"task_concept_id": "#V#task_1"}, + providers=( + _provider( + "provider_one", + [ + _open_action("shared.open", "Open shared"), + {"action_id": "not valid", "kind": "open_resource", "label": "Bad"}, + { + "action_id": "wrong.kind", + "kind": "submit", + "label": "Wrong kind", + }, + {"action_id": "missing.label", "kind": "open_resource"}, + ], + ), + _provider("provider_two", [_open_action("shared.open", "Duplicate")]), + ), + ) + + assert actions == [_open_action("shared.open", "Open shared")] + + +def test_list_actions_isolates_provider_listing_failure() -> None: + actions = list_task_external_resource_actions( + {"task_concept_id": "#V#task_1"}, + providers=( + _provider("broken_provider", RuntimeError("unavailable")), + _provider( + "healthy_provider", [_open_action("healthy.open", "Open healthy")] + ), + ), + ) + + assert actions == [_open_action("healthy.open", "Open healthy")] + + +def test_resolve_action_accepts_https_target() -> None: + target = resolve_task_external_resource_action( + {"task_concept_id": "#V#task_1"}, + "provider.open", + providers=( + _provider( + "provider", + [_open_action("provider.open", "Open resource")], + resolved_target="https://example.test/resources/one?view=full", + ), + ), + ) + + assert target == "https://example.test/resources/one?view=full" + + +@pytest.mark.parametrize( + "target", + [ + "http://example.test/resource", + "ftp://example.test/resource", + "https://user:password@example.test/resource", + ], +) +def test_resolve_action_rejects_non_https_or_credentialed_targets(target: str) -> None: + with pytest.raises(TaskExternalResourceActionResolutionError) as error: + resolve_task_external_resource_action( + {"task_concept_id": "#V#task_1"}, + "provider.open", + providers=( + _provider( + "provider", + [_open_action("provider.open", "Open resource")], + resolved_target=target, + ), + ), + ) + + assert error.value.reason_code == "external_resource_target_invalid" + + +def test_resolve_action_preserves_provider_access_error() -> None: + access_error = TaskExternalResourceActionAccessError( + reason_code="provider_access_denied", + safe_message="You cannot open this resource.", + ) + + with pytest.raises(TaskExternalResourceActionAccessError) as error: + resolve_task_external_resource_action( + {"task_concept_id": "#V#task_1"}, + "provider.open", + providers=( + _provider( + "provider", + [_open_action("provider.open", "Open resource")], + resolved_target=access_error, + ), + ), + ) + + assert error.value is access_error + + +def test_resolve_action_reports_not_found_when_no_provider_advertises_it() -> None: + with pytest.raises(TaskExternalResourceActionNotFoundError) as error: + resolve_task_external_resource_action( + {"task_concept_id": "#V#task_1"}, + "provider.missing", + providers=( + _provider("provider", [_open_action("provider.open", "Open resource")]), + ), + ) + + assert error.value.reason_code == "external_resource_action_not_found" diff --git a/tests/backend/test_task_routes_detail_endpoints.py b/tests/backend/test_task_routes_detail_endpoints.py index 35618c3b5..084374ea6 100644 --- a/tests/backend/test_task_routes_detail_endpoints.py +++ b/tests/backend/test_task_routes_detail_endpoints.py @@ -2,9 +2,15 @@ from __future__ import annotations +import pytest from flask import Flask from src.backend.server.routes.task_routes import task_bp +from src.backend.services.task_external_resource_action_service import ( + TaskExternalResourceActionAccessError, + TaskExternalResourceActionNotFoundError, + TaskExternalResourceActionResolutionError, +) def _build_client(): @@ -254,3 +260,120 @@ def _fake_unlink_tasks( assert captured["remove_target"] == "#V#task_2" assert captured["remove_link_type"] == "blocks" assert captured["remove_actor"] == "#V#user_alice" + + +def test_get_task_route_adds_local_hrefs_for_external_resource_actions(monkeypatch): + client = _build_client() + task = { + "task_concept_id": "#V#task_1", + "title": "Review source", + } + actions = [ + { + "action_id": "provider.open_source", + "kind": "open_resource", + "label": "Open source", + "source_system": "provider", + } + ] + + monkeypatch.setattr( + "src.backend.server.routes.task_routes.get_task", + lambda task_concept_id: task, + ) + monkeypatch.setattr( + "src.backend.server.routes.task_routes.list_task_external_resource_actions", + lambda task_value: actions, + ) + + response = client.get("/api/tasks/%23V%23task_1") + + assert response.status_code == 200 + assert response.get_json() == { + **task, + "external_resource_actions": [ + { + **actions[0], + "href": ( + "/api/tasks/%23V%23task_1/external-resource-actions/" + "provider.open_source" + ), + } + ], + } + + +def test_external_resource_action_route_redirects_to_resolved_target(monkeypatch): + client = _build_client() + monkeypatch.setattr( + "src.backend.server.routes.task_routes.get_task", + lambda task_concept_id: {"task_concept_id": task_concept_id}, + ) + monkeypatch.setattr( + "src.backend.server.routes.task_routes.resolve_task_external_resource_action", + lambda task, action_id: "https://provider.example.test/resources/one", + ) + + response = client.get( + "/api/tasks/%23V%23task_1/external-resource-actions/provider.open", + follow_redirects=False, + ) + + assert response.status_code == 302 + assert response.headers["Location"] == "https://provider.example.test/resources/one" + + +@pytest.mark.parametrize( + ("error", "expected_status"), + [ + ( + TaskExternalResourceActionNotFoundError( + reason_code="external_resource_action_not_found", + safe_message="No such action.", + ), + 404, + ), + ( + TaskExternalResourceActionAccessError( + reason_code="external_resource_access_denied", + safe_message="Access denied.", + ), + 403, + ), + ( + TaskExternalResourceActionResolutionError( + reason_code="external_resource_resolution_failed", + safe_message="Resolution failed.", + ), + 502, + ), + ], +) +def test_open_task_external_resource_action_route_maps_typed_errors( + monkeypatch, + error, + expected_status, +): + client = _build_client() + monkeypatch.setattr( + "src.backend.server.routes.task_routes.get_task", + lambda task_concept_id: {"task_concept_id": task_concept_id}, + ) + + def _raise_typed_error(task, action_id): + raise error + + monkeypatch.setattr( + "src.backend.server.routes.task_routes.resolve_task_external_resource_action", + _raise_typed_error, + ) + + response = client.get( + "/api/tasks/%23V%23task_1/external-resource-actions/provider.open" + ) + + assert response.status_code == expected_status + assert response.get_json() == { + "error": error.safe_message, + "reason_code": error.reason_code, + } diff --git a/tests/frontend/taskPanelConceptLinks.test.js b/tests/frontend/taskPanelConceptLinks.test.js index 1735f3662..81bc85699 100644 --- a/tests/frontend/taskPanelConceptLinks.test.js +++ b/tests/frontend/taskPanelConceptLinks.test.js @@ -210,4 +210,114 @@ describe('task panel concept links', () => { expect(document.querySelector('.task-inspector-card[data-task-id="#V#task_1282"]')).toBeTruthy(); expect(document.querySelector('#globalTaskInspector')?.textContent || '').toContain('Detail toggle check'); }); + + test('renders only safe open-resource task actions in the inspector', async () => { + const { getJson } = require(apiServiceModulePath); + const task = { + task_concept_id: '#V#task_1283', + title: 'External resource actions', + description: '', + status: 'pending', + priority: 'medium', + task_type_ids: ['#V#one_off_task_specification'], + task_source_id: '#V#von_native_task_source', + external_resource_actions: [ + { + kind: 'open_resource', + label: 'Open source mail', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/gmail.open_source', + }, + { + kind: 'open_resource', + label: 'Open calendar event', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/calendar-event', + }, + { + label: 'Missing action kind', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/missing-kind', + }, + { + kind: 'submit_resource', + label: 'Unsupported action kind', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/ignored-kind', + }, + { + kind: 'open_resource', + label: 'Unsafe remote link', + href: 'https://example.test/resource', + }, + { + kind: 'open_resource', + label: '', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/unsafe-label', + }, + { + kind: 'open_resource', + label: 'Malformed local link', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/', + }, + { + kind: 'open_resource', + label: 'Encoded path escape', + href: '/api/tasks/%23V%23task_1283/external-resource-actions/%2Funsafe', + }, + null, + ], + }; + getJson.mockImplementation((url) => { + if (url === '/api/tasks/taxonomy') { + return Promise.resolve(buildTaxonomyResponse()); + } + if (typeof url === 'string' && url.startsWith('/api/tasks/?') && url.includes('limit=50')) { + return Promise.resolve({ tasks: [task] }); + } + if (url === '/api/tasks/%23V%23task_1283') { + return Promise.resolve(task); + } + if (url === '/api/tasks/%23V%23task_1283/comments?limit=100') { + return Promise.resolve({ comments: [] }); + } + if (url === '/api/tasks/%23V%23task_1283/attachments?limit=100') { + return Promise.resolve({ attachments: [] }); + } + if (url === '/api/tasks/%23V%23task_1283/history?limit=200') { + return Promise.resolve({ history: [] }); + } + return Promise.resolve({}); + }); + + const { showGlobalTasks } = require(taskPanelModulePath); + await showGlobalTasks(); + document.querySelector('.task-detail-toggle-btn').click(); + await flushMicrotasks(); + await flushMicrotasks(); + + const actions = Array.from(document.querySelectorAll('.task-external-resource-action')); + expect(actions).toHaveLength(3); + expect(actions.map((action) => action.textContent.trim())).toEqual([ + 'Open source mail', + 'Open calendar event', + '', + ]); + expect(actions.map((action) => action.getAttribute('href'))).toEqual([ + '/api/tasks/%23V%23task_1283/external-resource-actions/gmail.open_source', + '/api/tasks/%23V%23task_1283/external-resource-actions/calendar-event', + '/api/tasks/%23V%23task_1283/external-resource-actions/unsafe-label', + ]); + actions.forEach((action) => { + expect(action.getAttribute('target')).toBe('_blank'); + expect(action.getAttribute('rel')).toBe('noopener noreferrer'); + }); + expect(document.querySelector('script')).toBeNull(); + expect(document.querySelector('#globalTaskInspector')?.textContent || '') + .not.toContain('Unsupported action kind'); + expect(document.querySelector('#globalTaskInspector')?.textContent || '') + .not.toContain('Missing action kind'); + expect(document.querySelector('#globalTaskInspector')?.textContent || '') + .not.toContain('Unsafe remote link'); + expect(document.querySelector('#globalTaskInspector')?.textContent || '') + .not.toContain('Malformed local link'); + expect(document.querySelector('#globalTaskInspector')?.textContent || '') + .not.toContain('Encoded path escape'); + }); });