From ba4675f6069f42d60c8dc20d0d38abc3a3420b28 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 8 Oct 2026 17:37:37 -0400 Subject: [PATCH 1/2] Reduce V2 group-switch bootstrap latency Confirm group selections through a protected scope-only endpoint instead of rebuilding bootstrap catalogs. Preserve access checks and response ordering, add bootstrap phase timings and regression coverage, and document version 0.261.305. Refs #1725 for the separate backend optimization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- application/single_app/route_backend_v2.py | 276 ++++++++++++++---- application/v2_ui/src/lib/apiClient.ts | 11 +- application/v2_ui/src/lib/endpoints.ts | 13 +- application/v2_ui/src/lib/types.ts | 18 +- .../v2_ui/src/stores/bootstrapStore.ts | 93 +++++- .../v2_ui/src/stores/groupWorkspaceStore.ts | 6 +- .../fixes/V2_GROUP_SWITCH_LATENCY_FIX.md | 124 ++++++++ docs/explanation/release_notes.md | 10 + docs/reference/logging-tags.md | 1 + .../test_route_blueprint_policy_inventory.py | 3 +- ...t_route_unauthenticated_policy_contract.py | 11 +- ...st_v2_admin_settings_live_shell_refresh.py | 8 +- ...st_v2_bootstrap_branding_and_navigation.py | 7 +- .../test_v2_bootstrap_refresh_logic.mjs | 111 ++++++- .../test_v2_group_workspace_context_logic.mjs | 78 ++++- .../test_v2_scope_and_bootstrap_timings.py | 197 +++++++++++++ ui_tests/fixtures/group_workspace.py | 13 +- ui_tests/test_v2_group_workspace_shell.py | 20 +- 19 files changed, 918 insertions(+), 84 deletions(-) create mode 100644 docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md create mode 100644 functional_tests/test_v2_scope_and_bootstrap_timings.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 10d13658b..9d6d17910 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.304" +VERSION = "0.261.305" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index ec304d463..b9d8e63d7 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -21,6 +21,7 @@ import json import logging +import time import uuid from flask import current_app, jsonify, request, session, url_for @@ -128,6 +129,7 @@ from functions_public_workspaces import ( find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings, + visible_public_workspace_ids_from_user_settings, ) from functions_prompt_variables import ( MAX_REQUEST_BYTES as PROMPT_FILL_MAX_REQUEST_BYTES, @@ -954,53 +956,88 @@ def v2_bootstrap(): if not user_id: return jsonify({"error": "User not authenticated"}), 401 + # Per-section timings, emitted as one trace per request. The Cosmos calls behind + # this payload are instrumented, but most of its time is spent in Python between + # them, which no dependency span covers. + timer = BootstrapPhaseTimer() try: settings = get_settings() + timer.mark("settings") public_settings = sanitize_settings_for_user(settings) + timer.mark("sanitize_settings") user_settings = get_user_settings(user_id) user_settings_dict = ( user_settings.get("settings", {}) if isinstance(user_settings, dict) else {} ) + timer.mark("user_settings") current_user_info = get_current_user_info() or {} session_user = session.get("user") or {} current_user_roles = session_user.get("roles", []) or [] + timer.mark("user_info") + measure = timer.measure per_user_overrides = { - "enable_chat_file_uploads": is_chat_file_upload_enabled_for_user( - settings, current_user_roles + "enable_chat_file_uploads": measure( + "chat_file_uploads", + is_chat_file_upload_enabled_for_user, + settings, + current_user_roles, ), - "allow_user_workflows": is_user_workflows_enabled_for_user( - settings, user_roles=current_user_roles + "allow_user_workflows": measure( + "user_workflows", + is_user_workflows_enabled_for_user, + settings, + user_roles=current_user_roles, ), - "enable_workflow_ai_assistant": is_workflow_assistant_enabled_for_user( - settings, user_roles=current_user_roles + "enable_workflow_ai_assistant": measure( + "workflow_assistant", + is_workflow_assistant_enabled_for_user, + settings, + user_roles=current_user_roles, ), # Only hides Ask AI; each assist route re-checks the scope permission. - "enable_agent_ai_assistant": is_agent_assistant_enabled(settings), - "enable_action_ai_assistant": is_action_assistant_enabled(settings), + "enable_agent_ai_assistant": measure( + "agent_assistant", is_agent_assistant_enabled, settings + ), + "enable_action_ai_assistant": measure( + "action_assistant", is_action_assistant_enabled, settings + ), # Only shows the Review center's AI entry points; every assist and suggestion # route re-checks the toggle and the caller's reviewer role. The guidance text # itself is never sent. - "enable_admin_review_ai_assistant": is_admin_review_assistant_enabled(settings), + "enable_admin_review_ai_assistant": measure( + "admin_review_assistant", is_admin_review_assistant_enabled, settings + ), # Only hides the chip and entry points; the server re-checks every read. - "enable_chat_workflow_results": is_chat_workflow_results_enabled_for_user( - settings, user_roles=current_user_roles + "enable_chat_workflow_results": measure( + "chat_workflow_results", + is_chat_workflow_results_enabled_for_user, + settings, + user_roles=current_user_roles, ), "enable_workflow_alert_sounds": ( settings.get("enable_workflow_alert_sounds", True) is not False ), - "enable_source_review": is_source_review_enabled_for_user( + "enable_source_review": measure( + "source_review", + is_source_review_enabled_for_user, settings, user_id, user_email=current_user_info.get("email"), user_roles=current_user_roles, ), - "enable_url_access": is_url_access_enabled_for_user( - settings, user_roles=current_user_roles + "enable_url_access": measure( + "url_access", + is_url_access_enabled_for_user, + settings, + user_roles=current_user_roles, ), - "enable_conversation_contents_drawer": is_conversation_contents_drawer_enabled( - public_settings, user_settings_dict + "enable_conversation_contents_drawer": measure( + "conversation_contents_drawer", + is_conversation_contents_drawer_enabled, + public_settings, + user_settings_dict, ), } @@ -1014,6 +1051,7 @@ def v2_bootstrap(): ] except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load user groups: {exc}") + timer.mark("groups") public_workspaces = [] try: @@ -1025,6 +1063,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load public workspaces: {exc}") + timer.mark("public_workspaces") agents = [] try: @@ -1041,6 +1080,7 @@ def v2_bootstrap(): ] except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load agent catalog: {exc}") + timer.mark("agent_catalog") models = [] try: @@ -1052,6 +1092,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load model catalog: {exc}") + timer.mark("model_catalog") prompts = [] try: @@ -1063,6 +1104,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to load prompt catalog: {exc}") + timer.mark("prompt_catalog") initial_model_selection = None try: @@ -1075,33 +1117,16 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to resolve initial model: {exc}") - - # The stored active scope is only a UI preference, so it is reported back - # rather than used to authorize anything here: the chat endpoints re-derive and - # authorize scope on every request via _get_authorized_chat_scope_context. - # - # It is still validated against the caller's own authorized lists, because a - # group or workspace can be revoked after the preference was saved and echoing - # a stale id would show the user a scope badge they no longer have access to. - # bac-check: ignore - preference read, filtered against the caller's authorized - # group and workspace lists below; not used for an authorization decision. - stored_group_id = user_settings_dict.get("activeGroupOid", "") or None - # bac-check: ignore - preference read, filtered against the caller's visible - # public workspace list below; not used for an authorization decision. - stored_workspace_id = user_settings_dict.get("activePublicWorkspaceOid", "") or None + timer.mark("initial_model") authorized_group_ids = {group["id"] for group in groups} - active_group_id = stored_group_id if stored_group_id in authorized_group_ids else None - active_group_name = None - if active_group_id: - group_doc = find_group_by_id(active_group_id) - if group_doc: - active_group_name = group_doc.get("name", "") - visible_workspace_ids = {workspace["id"] for workspace in public_workspaces} - active_public_workspace_id = ( - stored_workspace_id if stored_workspace_id in visible_workspace_ids else None + active_scope = _resolve_active_scope( + user_settings_dict, + authorized_group_ids, + lambda workspace_id: workspace_id in visible_workspace_ids, ) + timer.mark("active_scope") # Which workspace sections this user may see. Computed server-side because the # answer combines settings, app-role checks and governance policy, and only @@ -1118,6 +1143,7 @@ def v2_bootstrap(): ) except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to resolve workspace sections: {exc}") + timer.mark("workspace_sections") # Safety warnings an administrator sent that still need the user's # acknowledgment. Only the count rides here, so the interface asks for the @@ -1132,6 +1158,24 @@ def v2_bootstrap(): extra={"user_id": user_id, "error_type": type(exc).__name__}, level=logging.WARNING, ) + timer.mark("safety_warnings") + + branding = _build_branding(settings, public_settings) + timer.mark("branding") + navigation = _build_navigation(settings, current_user_roles) + timer.mark("navigation") + features = _build_feature_flags(public_settings, per_user_overrides) + timer.mark("features") + capabilities = _build_capabilities(settings) + timer.mark("capabilities") + control_center = get_control_center_capabilities(session_user, settings) + timer.mark("control_center") + orchestration = _build_orchestration(settings, public_settings) + timer.mark("orchestration") + notices = _build_notices(public_settings, user_settings_dict) + timer.mark("notices") + workspace_uploads = _build_workspace_uploads(public_settings) + timer.mark("workspace_uploads") payload = { "version": VERSION, @@ -1146,12 +1190,12 @@ def v2_bootstrap(): "is_admin": "Admin" in current_user_roles, "roles": list(current_user_roles), }, - "branding": _build_branding(settings, public_settings), - "navigation": _build_navigation(settings, current_user_roles), - "features": _build_feature_flags(public_settings, per_user_overrides), - "capabilities": _build_capabilities(settings), - "control_center": get_control_center_capabilities(session_user, settings), - "orchestration": _build_orchestration(settings, public_settings), + "branding": branding, + "navigation": navigation, + "features": features, + "capabilities": capabilities, + "control_center": control_center, + "orchestration": orchestration, "catalogs": { "models": models, "agents": agents, @@ -1159,21 +1203,21 @@ def v2_bootstrap(): "initial_model_selection": initial_model_selection, }, "scope": { - "active_group_id": active_group_id, - "active_group_name": active_group_name, - "active_public_workspace_id": active_public_workspace_id, + **active_scope, "groups": groups, "public_workspaces": public_workspaces, }, "admin_nav": ADMIN_NAV if "Admin" in current_user_roles else [], - "notices": _build_notices(public_settings, user_settings_dict), + "notices": notices, "workspace": workspace, - "workspace_uploads": _build_workspace_uploads(public_settings), + "workspace_uploads": workspace_uploads, "safety_warnings": {"pending": pending_safety_warnings}, "settings": public_settings, } - return jsonify(payload), 200 + response = jsonify(payload) + timer.mark("serialize") + return response, 200 except Exception as exc: log_event( f"[V2_BOOTSTRAP] Failed to build bootstrap payload: {exc}", @@ -1181,6 +1225,136 @@ def v2_bootstrap(): exceptionTraceback=True, ) return jsonify({"error": "Failed to load application bootstrap"}), 500 + finally: + timer.mark("response_tail") + timer.emit() + + @bp.route("/api/v2/scope", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def v2_scope(): + """Return only the caller's active group and public workspace. + + Switching group workspaces changes nothing else in the bootstrap payload, so the + SPA confirms a switch with this read instead of rebuilding every catalog. The + values are validated exactly as the bootstrap validates them, by the same helper. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated"}), 401, {"Cache-Control": "no-store"} + + try: + user_settings = get_user_settings(user_id) + user_settings_dict = ( + user_settings.get("settings", {}) if isinstance(user_settings, dict) else {} + ) + authorized_group_ids = { + group["id"] for group in (get_user_groups(user_id) or []) if group.get("id") + } + + def is_visible_workspace(workspace_id): + visible_ids = visible_public_workspace_ids_from_user_settings(user_settings) + if workspace_id not in visible_ids: + return False + return find_public_workspace_by_id(workspace_id) is not None + + active_scope = _resolve_active_scope( + user_settings_dict, authorized_group_ids, is_visible_workspace + ) + response = jsonify({"user": {"id": user_id}, "scope": active_scope}) + response.headers["Cache-Control"] = "no-store" + return response, 200 + except Exception as exc: + log_event( + "[V2_SCOPE] Failed to resolve active scope.", + extra={"error_type": type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"error": "Failed to load active scope"}), 503, {"Cache-Control": "no-store"} + + +class BootstrapPhaseTimer: + """Accumulate elapsed milliseconds per named section of one bootstrap request.""" + + def __init__(self, clock=time.perf_counter): + self._clock = clock + self._started = clock() + self._last = self._started + self.phases_ms = {} + + def mark(self, phase): + """Charge the time since the previous mark to ``phase``.""" + now = self._clock() + self.phases_ms[phase] = round( + self.phases_ms.get(phase, 0.0) + (now - self._last) * 1000.0, 1 + ) + self._last = now + + def measure(self, phase, func, *args, **kwargs): + """Call ``func`` and charge its duration to ``phase``, even when it raises.""" + self.mark("_between") + try: + return func(*args, **kwargs) + finally: + self.mark(phase) + + def total_ms(self): + return round((self._clock() - self._started) * 1000.0, 1) + + def emit(self): + """Log the timings as one trace, correlated to the request by operation id. + + ``log_event`` keeps only numeric values from ``extra`` (nested dicts collapse to a + count and strings to a length), so each phase is a flat ``phase__ms`` key. + """ + extra = {"total_ms": self.total_ms()} + for phase, elapsed_ms in self.phases_ms.items(): + if phase != "_between": + extra[f"phase_{phase}_ms"] = elapsed_ms + log_event("[V2_BOOTSTRAP] Phase timings", extra=extra) + + +def _resolve_active_scope(user_settings_dict, authorized_group_ids, is_visible_workspace): + """Report the caller's stored active group and public workspace, if still authorized. + + The stored active scope is only a UI preference, so it is reported back rather than + used to authorize anything: the chat endpoints re-derive and authorize scope on every + request via _get_authorized_chat_scope_context. + + It is still validated against the caller's own authorized lists, because a group or + workspace can be revoked after the preference was saved and echoing a stale id would + show the user a scope badge they no longer have access to. Both the bootstrap and the + scope endpoint use this helper, so the two cannot disagree on a user's active scope. + """ + user_settings_dict = user_settings_dict if isinstance(user_settings_dict, dict) else {} + # bac-check: ignore - preference read, filtered against the caller's authorized + # group list below; not used for an authorization decision. + stored_group_id = user_settings_dict.get("activeGroupOid", "") or None + # bac-check: ignore - preference read, filtered against the caller's visible + # public workspace list below; not used for an authorization decision. + stored_workspace_id = user_settings_dict.get("activePublicWorkspaceOid", "") or None + + active_group_id = stored_group_id if stored_group_id in authorized_group_ids else None + active_group_name = None + if active_group_id: + group_doc = find_group_by_id(active_group_id) + if group_doc: + active_group_name = group_doc.get("name", "") + else: + active_group_id = None + + active_public_workspace_id = ( + stored_workspace_id + if stored_workspace_id and is_visible_workspace(stored_workspace_id) + else None + ) + return { + "active_group_id": active_group_id, + "active_group_name": active_group_name, + "active_public_workspace_id": active_public_workspace_id, + } def _load_global_model_endpoints(settings=None): diff --git a/application/v2_ui/src/lib/apiClient.ts b/application/v2_ui/src/lib/apiClient.ts index f16582a7a..23336054e 100644 --- a/application/v2_ui/src/lib/apiClient.ts +++ b/application/v2_ui/src/lib/apiClient.ts @@ -68,6 +68,11 @@ interface RequestOptions { body?: unknown; signal?: AbortSignal; headers?: Record; + /** + * Fetch cache mode. Preference confirmations use `no-store` so an earlier cached + * response cannot stand in for a fresh server read. + */ + cache?: RequestCache; } /** A bare machine code, one lowercase token such as `document_propagation_incomplete`. */ @@ -187,7 +192,7 @@ export interface ApiResponse { } export async function requestWithStatus(path: string, options: RequestOptions = {}): Promise> { - const { method = 'GET', body, signal, headers = {} } = options; + const { method = 'GET', body, signal, headers = {}, cache } = options; const init: RequestInit = { method, @@ -199,6 +204,10 @@ export async function requestWithStatus(path: string, options: RequestOptions }, }; + if (cache) { + init.cache = cache; + } + if (body !== undefined) { init.headers = { ...init.headers, 'Content-Type': 'application/json' }; init.body = JSON.stringify(body); diff --git a/application/v2_ui/src/lib/endpoints.ts b/application/v2_ui/src/lib/endpoints.ts index 63a0b18e6..a1652873f 100644 --- a/application/v2_ui/src/lib/endpoints.ts +++ b/application/v2_ui/src/lib/endpoints.ts @@ -5,7 +5,7 @@ // route_backend_chats.py, route_backend_documents.py and functions_conversation_feed.py. // Keeping them in one module means a backend path change is a one-line edit here. -import { api, apiUrl, uploadFile, ApiError, API_BASE, CREDENTIALS_MODE } from './apiClient'; +import { api, apiUrl, request, uploadFile, ApiError, API_BASE, CREDENTIALS_MODE } from './apiClient'; import { buildDocumentListParams } from './documentExplorer'; import { artifactDownloadPath, artifactFileName } from './generatedArtifacts'; import { ANALYSIS_PAGE_SIZE, analysisResultContext, validateAnalysisPage } from './savedAnalysis'; @@ -14,6 +14,7 @@ import type { ExportVisualAsset } from './exportVisuals'; import type { GeneratedArtifact, GeneratedRunStatus } from './generatedArtifacts'; import type { MaskAction, MaskedRange, MaskSelection } from './masking'; import type { + ActiveScopePayload, AiNoticeFrequency, BootstrapPayload, ChatMessage, @@ -74,8 +75,16 @@ export async function fetchAnalysisEvidence( /* Bootstrap */ /* -------------------------------------------------------------------------- */ +/* + * Keep these account-specific reads out of the browser's HTTP cache, including any + * same-URL request coalescing while another bootstrap read is in flight. + */ export const fetchBootstrap = (signal?: AbortSignal) => - api.get('/api/v2/bootstrap', signal); + request('/api/v2/bootstrap', { method: 'GET', signal, cache: 'no-store' }); + +/** Only the caller's active group and public workspace, validated as bootstrap does. */ +export const fetchActiveScope = (signal?: AbortSignal) => + request('/api/v2/scope', { method: 'GET', signal, cache: 'no-store' }); /** * Record that the caller has dismissed the AI notice. diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index 89624e5e1..ccf9b176a 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -1150,6 +1150,19 @@ export interface ControlCenterCapabilities { can_run_maintenance: boolean; } +/** The caller's saved active scope, as bootstrap reports it under `scope`. */ +export interface ActiveScope { + active_group_id: string | null; + active_group_name: string | null; + active_public_workspace_id: string | null; +} + +/** GET /api/v2/scope: the active scope alone, for confirming a workspace switch. */ +export interface ActiveScopePayload { + user: { id: string }; + scope: ActiveScope; +} + export interface BootstrapPayload { version: string; user: { @@ -1219,10 +1232,7 @@ export interface BootstrapPayload { * `useBootstrapStore`. */ orchestration?: OrchestrationBootstrap; - scope: { - active_group_id: string | null; - active_group_name: string | null; - active_public_workspace_id: string | null; + scope: ActiveScope & { groups: WorkspaceRef[]; public_workspaces: WorkspaceRef[]; }; diff --git a/application/v2_ui/src/stores/bootstrapStore.ts b/application/v2_ui/src/stores/bootstrapStore.ts index 6910ef296..b8a8f9d50 100644 --- a/application/v2_ui/src/stores/bootstrapStore.ts +++ b/application/v2_ui/src/stores/bootstrapStore.ts @@ -4,7 +4,7 @@ // from here rather than refetching. import { create } from 'zustand'; -import { fetchBootstrap } from '../lib/endpoints'; +import { fetchActiveScope, fetchBootstrap } from '../lib/endpoints'; import { ApiError, isAccessRestricted, isTermsOfUseRequired } from '../lib/apiClient'; import type { BootstrapPayload, PromptOption } from '../lib/types'; @@ -17,6 +17,37 @@ import type { BootstrapPayload, PromptOption } from '../lib/types'; */ let refreshSequence = 0; +/** + * Bumped whenever `refreshScope` starts or commits. + * + * A workspace switch confirms the new active group with a scope-only read. A full bootstrap + * read that left before that one -- a tab refocus, say -- may have been built before the + * switch was saved, so when it lands it must not put the previous group back. Its scope + * fields are dropped in favour of the ones already in the store; everything else applies. + */ +let scopeSequence = 0; + +/** Install a full payload, keeping the newer active scope if one arrived after it left. */ +function withCurrentScope( + data: BootstrapPayload, + startedAtScope: number, + current: BootstrapPayload | null, +): BootstrapPayload { + if (startedAtScope === scopeSequence || !current?.scope || !data?.scope + || current.user?.id !== data.user?.id) { + return data; + } + return { + ...data, + scope: { + ...data.scope, + active_group_id: current.scope.active_group_id, + active_group_name: current.scope.active_group_name, + active_public_workspace_id: current.scope.active_public_workspace_id, + }, + }; +} + /** * When a bootstrap read last started, and how many are in flight. * @@ -59,6 +90,14 @@ interface BootstrapState { refreshWhenStale: (minIntervalMs: number) => Promise; /** An identity-bound selection must not install a response from a different sign-in. */ refreshRequired: (expectedViewerId?: string) => Promise; + /** + * Re-read only the active group and public workspace, and patch them in place. + * + * Switching group workspaces changes nothing else in the payload, and the full read + * takes seconds to build. Holds the same identity guard as `refreshRequired`, and + * throws if a newer scope read started while this one was running. + */ + refreshScope: (expectedViewerId: string) => Promise; /** * Put a prompt into the catalog straight away, before a refresh has been round-tripped. * @@ -80,9 +119,10 @@ export const useBootstrapStore = create((set, get) => ({ load: async () => { set({ loading: true, error: null, authExpired: false }); const settle = startBootstrapRead(); + const startedAtScope = scopeSequence; try { const data = await fetchBootstrap(); - set({ data, loading: false }); + set({ data: withCurrentScope(data, startedAtScope, get().data), loading: false }); } catch (error) { // The terms gate or the access gate refused the call and apiClient is already // navigating to the page that explains it. Staying on the boot screen avoids @@ -111,10 +151,11 @@ export const useBootstrapStore = create((set, get) => ({ refresh: async () => { const sequence = ++refreshSequence; const settle = startBootstrapRead(); + const startedAtScope = scopeSequence; try { const data = await fetchBootstrap(); if (sequence === refreshSequence) { - set({ data }); + set({ data: withCurrentScope(data, startedAtScope, get().data) }); } } catch { // Advisory on purpose, and the reason this cannot be `load()`. App.tsx @@ -138,6 +179,7 @@ export const useBootstrapStore = create((set, get) => ({ refreshRequired: async (expectedViewerId) => { const sequence = ++refreshSequence; const settle = startBootstrapRead(); + const startedAtScope = scopeSequence; let data: BootstrapPayload; try { data = await fetchBootstrap(); @@ -151,6 +193,51 @@ export const useBootstrapStore = create((set, get) => ({ || data?.user?.id !== expectedViewerId)) { throw new Error('Your sign-in changed during refresh. Reload the workspace.'); } + data = withCurrentScope(data, startedAtScope, get().data); + set({ data }); + return data; + }, + + refreshScope: async (expectedViewerId) => { + const previous = get().data; + if (get().authExpired || previous?.user?.id !== expectedViewerId) { + throw new Error('Your sign-in changed during refresh. Reload the workspace.'); + } + const sequence = ++scopeSequence; + const result = await fetchActiveScope(); + if (sequence !== scopeSequence) { + throw new Error('The active workspace changed during refresh. Try again.'); + } + const current = get().data; + if (!current || get().authExpired || current.user?.id !== expectedViewerId + || result?.user?.id !== expectedViewerId) { + throw new Error('Your sign-in changed during refresh. Reload the workspace.'); + } + const scope = result?.scope; + if (!scope || typeof scope !== 'object' + || ![scope.active_group_id, scope.active_public_workspace_id].every( + (id) => id === null || (typeof id === 'string' && id.trim().length > 0), + ) + || !(scope.active_group_name === null || typeof scope.active_group_name === 'string')) { + throw new Error('The active workspace could not be read.'); + } + if ((['active_group_id', 'active_public_workspace_id'] as const).some( + (key) => current.scope?.[key] !== previous.scope?.[key] + && current.scope?.[key] !== scope[key], + )) { + throw new Error('The active workspace changed during refresh. Try again.'); + } + const data: BootstrapPayload = { + ...current, + scope: { + ...current.scope, + active_group_id: scope.active_group_id, + active_group_name: scope.active_group_name, + active_public_workspace_id: scope.active_public_workspace_id, + }, + }; + // A full read may have started while this scope read was pending. + ++scopeSequence; set({ data }); return data; }, diff --git a/application/v2_ui/src/stores/groupWorkspaceStore.ts b/application/v2_ui/src/stores/groupWorkspaceStore.ts index c6ecb7d17..471032685 100644 --- a/application/v2_ui/src/stores/groupWorkspaceStore.ts +++ b/application/v2_ui/src/stores/groupWorkspaceStore.ts @@ -160,7 +160,9 @@ export const useGroupWorkspaceStore = create((set, get) => await GROUP_WORKSPACES.setActive(groupId); writeAcknowledged = true; assertCurrent(token, viewerId); - const bootstrap = await useBootstrapStore.getState().refreshRequired(viewerId); + // Only the active scope changes on a switch, so it is confirmed with the scope + // read rather than the full bootstrap, which takes seconds to rebuild. + const bootstrap = await useBootstrapStore.getState().refreshScope(viewerId); assertCurrent(token, viewerId); if (bootstrap.scope?.active_group_id !== groupId) { throw new Error('The active group changed during selection.'); @@ -199,7 +201,7 @@ export const useGroupWorkspaceStore = create((set, get) => set({ context: null, loading: true, refreshing: false, needsRevalidation: false, needsReconciliation: true, error: null }); try { // Recovery is read-only: never replay an ambiguously acknowledged mutation. - const bootstrap = await useBootstrapStore.getState().refreshRequired(viewerId); + const bootstrap = await useBootstrapStore.getState().refreshScope(viewerId); assertCurrent(token, viewerId); const groupId = bootstrap.scope?.active_group_id; const context = groupId diff --git a/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md b/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md new file mode 100644 index 000000000..9520f7f48 --- /dev/null +++ b/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md @@ -0,0 +1,124 @@ +# V2 Group Switch Latency Fix + +Fixed in version: **0.261.305** + +Related backend follow-up: [#1725](https://github.com/microsoft/simplechat/issues/1725). +The application version is recorded in `application/single_app/config.py`. + +## Issue and root cause + +Switching group workspaces waited for the complete V2 bootstrap response between +persisting the selection and reading the selected workspace's fresh context. +Production telemetry showed bootstrap taking approximately 5.1 seconds at the +median, compared with roughly 0.1 seconds for saving the selection and 0.15 seconds +for a workspace context read. A switch could therefore wait several seconds for +catalogs and other startup data that the selection itself did not change. + +Some observed switches also coincided with an earlier bootstrap read. Their next +bootstrap request reached the server just after the earlier request finished. +Browser same-URL request coalescing is a possible explanation, not a confirmed +root cause: the telemetry lacks browser queueing information and user/session +identifiers. These timelines cannot prove all neighboring requests belonged to +the same browser. + +## Technical changes + +### Lightweight, server-authoritative confirmation + +`GET /api/v2/scope` returns the authenticated viewer id and three active-scope +fields: `active_group_id`, `active_group_name`, and +`active_public_workspace_id`. It requires a signed-in, unrestricted User or +Admin session and is registered on the existing protected V2 Blueprint. + +The endpoint reads the caller's saved preferences, current groups, and any +selected public workspace. It shares `_resolve_active_scope` with bootstrap, +filters unavailable selections, and returns `Cache-Control: no-store`. +Storage failures return an explicit 503 rather than an empty successful +selection. It sends no application settings or catalog content to the browser. + +The group-switch sequence is now: + +1. Confirm that leaving the current page is allowed. +2. Read the target workspace context to validate access. +3. Persist the active group through the existing `PATCH /api/groups/setActive`. +4. Confirm the persisted selection through `GET /api/v2/scope`. +5. Read the target context again before exposing editable workspace resources. + +The preflight and final context reads remain because they validate current access. +A missing write acknowledgement still requires read-only reconciliation and +never causes the client to replay a possibly committed PATCH. + +### Client state and response ordering + +`bootstrapStore.refreshScope` validates the viewer and response shape and changes +only the three active-scope fields, preserving cached catalogs and workspace +lists. The group store uses this action for activation and reconciliation. +Responses from another sign-in or an older scope request cannot install state. +If another refresh has already installed a competing selection while the scope +read was pending, the scope read reports a conflict instead of hiding that change. + +A full bootstrap that overlaps a scope refresh still updates branding, features, +and catalogs, but cannot restore an older active selection when it arrives late. +Bootstrap and scope fetches explicitly use `cache: 'no-store'`; other API requests +retain their existing cache behavior. + +### Bootstrap phase timing + +Each authenticated bootstrap execution emits one `[V2_BOOTSTRAP] Phase timings` +event, including failed executions. `time.perf_counter` measures settings reads +and sanitization, user settings, individual feature predicates, groups, public +workspaces, catalogs, scope resolution, workspace eligibility, safety warnings, +shell projections, and JSON serialization. + +Timings are flat numeric properties because `log_event` reduces nested objects +to counts. Logging adds the `sc_` prefix, producing properties such as +`sc_total_ms` and `sc_phase_model_catalog_ms`. No user identity, settings contents, +catalog entries, or credentials are included in this timing event. + +Example Application Insights query: + +```kusto +traces +| where timestamp > ago(1d) +| where tostring(customDimensions.sc_message) == "[V2_BOOTSTRAP] Phase timings" +| extend total_ms = todouble(customDimensions.sc_total_ms), + models_ms = todouble(customDimensions.sc_phase_model_catalog_ms), + review_ms = todouble(customDimensions.sc_phase_source_review_ms) +| summarize p50_total_ms = percentile(total_ms, 50), + p90_total_ms = percentile(total_ms, 90), + p50_models_ms = percentile(models_ms, 50), + p50_review_ms = percentile(review_ms, 50) +``` + +## Files and impact + +Backend changes are in `route_backend_v2.py` and `config.py`. Frontend changes +are in `apiClient.ts`, `endpoints.ts`, `types.ts`, `bootstrapStore.ts`, and +`groupWorkspaceStore.ts`. The shared legacy active-group PATCH is unchanged. +The production V2 bundle is generated using `npm run build` in +`application/v2_ui`; generated assets remain outside source control. + +First-load bootstrap still builds the complete startup payload. This fix removes +that work from group switching; it does not claim to fix initial-load latency. +The instrumentation informs the broader backend optimization tracked in #1725. + +## Validation + +Functional coverage includes `test_v2_scope_and_bootstrap_timings.py`, +`test_v2_bootstrap_refresh_logic.mjs`, and +`test_v2_group_workspace_context_logic.mjs`. Cases cover response shape, authorized +scope filtering, explicit storage failure, anonymous access, flat numeric timings, +overlapping bootstrap/scope reads, sign-in changes, cancellation, competing +selections, revoked access, and read-only recovery. + +`ui_tests/test_v2_group_workspace_shell.py` exercises the real built SPA with +isolated HTTP fixtures. Desktop and mobile switch checks assert that a scope +confirmation replaces the extra bootstrap read, and recovery checks ensure an +ambiguous switch does not replay the PATCH. The existing route policy tests cover +the new endpoint's authenticated Blueprint and user-session requirements. + +The removed bootstrap wait suggests a substantial switch improvement, but the +approximately one-second estimate is not a measured production result. After +deployment, compare repeated stopwatch and DevTools measurements, scope endpoint +latency, and bootstrap phase timings. Live Azure latency and first-load LCP are +not validated by isolated functional or browser tests. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 62012a6f8..377a08fbe 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.305)** + +#### Bug Fixes + +* **V2 Group Switching No Longer Rebuilds All Startup Data** + * A group switch now confirms the saved selection through a lightweight scope read instead of waiting for the complete startup payload. Current-access checks, sign-in checks, and read-only recovery after an uncertain save remain in place. + * An overlapping startup refresh cannot restore the previous selection. Startup and scope reads bypass the browser's HTTP cache. + * Startup requests now report per-phase timing so the remaining first-load bottleneck can be measured; first-load optimization is a separate follow-up. + * (Ref: `route_backend_v2.py`, `bootstrapStore.ts`, `groupWorkspaceStore.ts`, [V2 Group Switch Latency Fix](fixes/V2_GROUP_SWITCH_LATENCY_FIX.md), [#1725](https://github.com/microsoft/simplechat/issues/1725)) + ### **(v0.261.304)** #### Bug Fixes diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index 67868118c..24a0b2d2c 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -287,6 +287,7 @@ Last inventoried: 2026-08-10 - `[XSD_GENERATION]` - `[XSD_INGESTION]` - `[V2_BOOTSTRAP]` +- `[V2_SCOPE]` - `[VIDEO]` - `[VIDEO_CHUNK]` - `[VIDEO_INDEXER]` diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index d126dba5a..92ff4d304 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for route blueprint policy inventory. -Version: 0.261.299 +Version: 0.261.305 Implemented in: 0.242.069 Plan editor policy coverage: 0.261.102 Selected-group context policy coverage: 0.261.126 @@ -355,6 +355,7 @@ # The warned user's own safety warnings: a user session, deliberately not gated on the # content checks report, so a warning already sent stays acknowledgeable. ("route_backend_safety.py", "get_pending_safety_warnings"): ("login_required", "user_required"), + ("route_backend_v2.py", "v2_scope"): ("login_required", "user_required"), ("route_backend_safety.py", "acknowledge_pending_safety_warning"): ("login_required", "user_required"), # The Review center: each section's reviewer role and its feature gate, like the # single-record review routes they sit beside. diff --git a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py index 32204299e..ca6647958 100644 --- a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py +++ b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py @@ -2,7 +2,7 @@ # test_route_unauthenticated_policy_contract.py """ Functional test for route unauthenticated access policy contract. -Version: 0.261.299 +Version: 0.261.305 Implemented in: 0.242.069 Workflow result context coverage: 0.261.214 Workflow run status coverage: 0.261.227 @@ -288,6 +288,15 @@ def test_workflow_result_context_requires_a_signed_in_user_session() -> None: assert {"login_required", "user_required"} <= set(matches[0].decorator_names) +def test_v2_scope_requires_a_signed_in_user_session() -> None: + """The lightweight scope confirmation is never public or bearer-only.""" + path = "/api/v2/scope" + matches = [route for route in iter_route_functions() if route.path == path] + assert [route.function_name for route in matches] == ["v2_scope"] + assert expected_policy(path) == "session_user_401_or_redirect" + assert {"swagger_route", "login_required", "user_required"} <= set(matches[0].decorator_names) + + def test_workflow_run_status_requires_a_signed_in_user_session() -> None: """The run card's status rows are never public or bearer-only. diff --git a/functional_tests/test_v2_admin_settings_live_shell_refresh.py b/functional_tests/test_v2_admin_settings_live_shell_refresh.py index c359f86df..dee3b59b6 100644 --- a/functional_tests/test_v2_admin_settings_live_shell_refresh.py +++ b/functional_tests/test_v2_admin_settings_live_shell_refresh.py @@ -1,8 +1,9 @@ +# test_v2_admin_settings_live_shell_refresh.py #!/usr/bin/env python3 """ Functional test for the V2 interface refreshing its shell after an admin save. -Version: 0.261.046 +Version: 0.261.305 Implemented in: 0.261.046 An administrator enabled the classification banner in the V2 admin settings, saved, and @@ -78,7 +79,10 @@ def test_the_store_can_reread_bootstrap(): "classification banner exists at all -- it is emitted only when enabled and " "non-empty -- and applies its colour defaults, so the browser cannot re-derive it" ) - assert "set({ data })" in body, "The refresh must apply the payload it fetched" + assert "set({ data: withCurrentScope(data," in body, ( + "The refresh must apply its fetched branding and catalogs while preserving " + "a newer active workspace selection" + ) print("Bootstrap refresh action test passed!") return True diff --git a/functional_tests/test_v2_bootstrap_branding_and_navigation.py b/functional_tests/test_v2_bootstrap_branding_and_navigation.py index 87c310030..3af478223 100644 --- a/functional_tests/test_v2_bootstrap_branding_and_navigation.py +++ b/functional_tests/test_v2_bootstrap_branding_and_navigation.py @@ -2,7 +2,7 @@ # test_v2_bootstrap_branding_and_navigation.py """ Functional test for the V2 bootstrap branding and navigation blocks. -Version: 0.261.296 +Version: 0.261.305 Implemented in: 0.261.047 Send Feedback navigation entry added in: 0.261.296 @@ -460,7 +460,10 @@ def test_bootstrap_payload_carries_the_navigation_block(): source = BACKEND_V2.read_text(encoding="utf-8") - assert '"navigation": _build_navigation(settings, current_user_roles)' in source, ( + assert 'navigation = _build_navigation(settings, current_user_roles)' in source, ( + "The timed navigation builder must still use settings and the caller's roles" + ) + assert '"navigation": navigation' in source, ( "The bootstrap payload must carry a navigation block built from the caller's " "roles, or Custom Pages and External Links cannot appear in the V2 rail" ) diff --git a/functional_tests/test_v2_bootstrap_refresh_logic.mjs b/functional_tests/test_v2_bootstrap_refresh_logic.mjs index 47035f178..ed8fd60f1 100644 --- a/functional_tests/test_v2_bootstrap_refresh_logic.mjs +++ b/functional_tests/test_v2_bootstrap_refresh_logic.mjs @@ -1,9 +1,10 @@ // test_v2_bootstrap_refresh_logic.mjs // // Runtime test for the V2 bootstrap store's refresh action. -// Version: 0.261.096 +// Version: 0.261.305 // Implemented in: 0.261.046 // Required authoring selection refresh added in: 0.261.096 +// Scope-only refresh for group switching added in: 0.261.305 (#1725) // // The companion test, test_v2_admin_settings_live_shell_refresh.py, asserts that the pieces // are wired together. Those are source assertions: they prove the call exists, not that it @@ -301,6 +302,108 @@ async function testSupersededRequiredRefreshCannotAuthorizeFromAnOlderResult() { assert.equal(useBootstrapStore.getState().data, latest); } +async function testScopeRefreshPatchesOnlyTheActiveScope() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', active_group_name: 'One', active_public_workspace_id: null, groups: [{ id: 'g1' }, { id: 'g2' }], public_workspaces: [] }; + loaded.catalogs = { agents: [{ id: 'kept' }] }; + let seen; + fetchImpl = async (path, init) => { + seen = { path: String(path), cache: init?.cache }; + return jsonResponse({ user: { id: 'u1' }, scope: { active_group_id: 'g2', active_group_name: 'Two', active_public_workspace_id: null } }); + }; + const result = await useBootstrapStore.getState().refreshScope('u1'); + assert.equal(seen.path, '/api/v2/scope'); + assert.equal(seen.cache, 'no-store', 'A group switch must not queue behind a cached bootstrap read'); + assert.equal(result.scope.active_group_id, 'g2'); + assert.equal(result.scope.active_group_name, 'Two'); + assert.equal(result.scope.groups, loaded.scope.groups); + assert.equal(result.catalogs, loaded.catalogs); + assert.equal(result.branding, loaded.branding); + assert.equal(useBootstrapStore.getState().data, result); +} + +async function testScopeRefreshRejectsAnotherViewer() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + fetchImpl = async () => jsonResponse({ user: { id: 'someone-else' }, scope: { active_group_id: 'g2' } }); + await assert.rejects(useBootstrapStore.getState().refreshScope('u1'), /sign-in changed/); + await assert.rejects(useBootstrapStore.getState().refreshScope('not-u1'), /sign-in changed/); + fetchImpl = async () => jsonResponse({ user: { id: 'u1' } }); + await assert.rejects(useBootstrapStore.getState().refreshScope('u1'), /could not be read/); + assert.equal(useBootstrapStore.getState().data, loaded); +} + +async function testSupersededScopeRefreshCannotApply() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + let release; + fetchImpl = () => new Promise((resolve) => { release = resolve; }); + const older = useBootstrapStore.getState().refreshScope('u1'); + const releaseOlder = release; + fetchImpl = async () => jsonResponse({ + user: { id: 'u1' }, + scope: { active_group_id: 'g3', active_group_name: 'Three', active_public_workspace_id: null }, + }); + await useBootstrapStore.getState().refreshScope('u1'); + releaseOlder(jsonResponse({ user: { id: 'u1' }, scope: { active_group_id: 'g2' } })); + await assert.rejects(older, /active workspace changed/); + assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'g3'); +} + +async function testMalformedScopeIsNotAnEmptySelection() { + const loaded = (await loadWith(BANNER_ON)).data; + for (const scope of [ + {}, { active_group_id: 12, active_group_name: null, active_public_workspace_id: null }, + { active_group_id: '', active_group_name: null, active_public_workspace_id: null }, + { active_group_id: null, active_group_name: {}, active_public_workspace_id: null }, + ]) { + fetchImpl = async () => jsonResponse({ user: { id: 'u1' }, scope }); + await assert.rejects(useBootstrapStore.getState().refreshScope('u1'), /could not be read/); + assert.equal(useBootstrapStore.getState().data, loaded); + } +} + +async function testBootstrapStartedDuringScopeRefreshCannotRestoreOldSelection() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + let releaseScope; + let releaseBootstrap; + fetchImpl = (path) => new Promise((resolve) => { + if (String(path) === '/api/v2/scope') releaseScope = resolve; + else releaseBootstrap = resolve; + }); + const scopeRead = useBootstrapStore.getState().refreshScope('u1'); + const fullRead = useBootstrapStore.getState().refresh(); + releaseScope(jsonResponse({ + user: { id: 'u1' }, + scope: { active_group_id: 'g2', active_group_name: 'Two', active_public_workspace_id: null }, + })); + await scopeRead; + const stale = payloadWithBanner(BANNER_OFF); + stale.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + releaseBootstrap(jsonResponse(stale)); + await fullRead; + assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'g2'); + assert.equal(useBootstrapStore.getState().data.branding.classification_banner, BANNER_OFF); +} + +async function testScopeRefreshCannotHideACompetingSelectionAlreadyInstalled() { + const loaded = (await loadWith(BANNER_ON)).data; + loaded.scope = { active_group_id: 'g1', groups: [], public_workspaces: [] }; + let releaseScope; + fetchImpl = () => new Promise((resolve) => { releaseScope = resolve; }); + const scopeRead = useBootstrapStore.getState().refreshScope('u1'); + const competing = payloadWithBanner(BANNER_ON); + competing.scope = { active_group_id: 'g3', active_public_workspace_id: null }; + useBootstrapStore.setState({ data: competing }); + releaseScope(jsonResponse({ + user: { id: 'u1' }, + scope: { active_group_id: 'g2', active_group_name: 'Two', active_public_workspace_id: null }, + })); + await assert.rejects(scopeRead, /active workspace changed/); + assert.equal(useBootstrapStore.getState().data, competing); +} + const tests = [ testRefreshAppliesTheNewPayload, testRefreshNeverBlanksTheInterface, @@ -310,6 +413,12 @@ const tests = [ testRequiredRefreshReturnsFreshDataWithoutBlanking, testRequiredRefreshRejectsInsteadOfReturningCachedAgents, testSupersededRequiredRefreshCannotAuthorizeFromAnOlderResult, + testScopeRefreshPatchesOnlyTheActiveScope, + testScopeRefreshRejectsAnotherViewer, + testSupersededScopeRefreshCannotApply, + testMalformedScopeIsNotAnEmptySelection, + testBootstrapStartedDuringScopeRefreshCannotRestoreOldSelection, + testScopeRefreshCannotHideACompetingSelectionAlreadyInstalled, ]; let passed = 0; diff --git a/functional_tests/test_v2_group_workspace_context_logic.mjs b/functional_tests/test_v2_group_workspace_context_logic.mjs index f447eee1b..d2a1405e6 100644 --- a/functional_tests/test_v2_group_workspace_context_logic.mjs +++ b/functional_tests/test_v2_group_workspace_context_logic.mjs @@ -1,10 +1,11 @@ // test_v2_group_workspace_context_logic.mjs -// Version: 0.261.296 +// Version: 0.261.305 // Implemented in: 0.261.126 // Shared shell navigation and revalidation: 0.261.127 // Members section validation (M7B): 0.261.155 // Workspace navigation URL validation: 0.261.281 // User Settings Open path for a group row: 0.261.296 +// Switch confirmed by the scope-only read, not the full bootstrap (#1725): 0.261.305 // Executes the real context API and stores with controlled HTTP ordering. import assert from 'node:assert/strict'; @@ -68,6 +69,17 @@ function bootstrap(viewer = serverViewer, active = activeGroup) { }; } +function activeScope(viewer = serverViewer, active = activeGroup) { + return { + user: { id: viewer }, + scope: { + active_group_id: active, + active_group_name: active ? `Name ${active}` : null, + active_public_workspace_id: null, + }, + }; +} + function json(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } @@ -81,6 +93,7 @@ function defaultResponse(call) { return json({ message: 'Active group updated' }); } if (call.path === '/api/v2/bootstrap') return json(bootstrap()); + if (call.path === '/api/v2/scope') return json(activeScope()); throw new Error(`Unexpected request: ${call.method} ${call.path}`); } @@ -101,7 +114,10 @@ async function run(name, check) { } globalThis.fetch = async (path, options = {}) => { - const call = { path: String(path), method: options.method ?? 'GET', body: options.body ? JSON.parse(options.body) : null, signal: options.signal }; + const call = { + path: String(path), method: options.method ?? 'GET', + body: options.body ? JSON.parse(options.body) : null, signal: options.signal, cache: options.cache, + }; calls.push(call); return handler(call); }; @@ -229,7 +245,7 @@ try { await assert.rejects(loading); assert.equal(useGroupWorkspaceStore.getState().context, null); }); - await run('activation confirms drafts, authorization, persistence, catalogs and fresh context in order', async () => { + await run('activation confirms drafts, authorization, persistence, scope and fresh context in order', async () => { await useGroupWorkspaceStore.getState().load('group-a'); calls = []; let confirmed = false; @@ -242,11 +258,53 @@ try { assert.equal(result.context.scope.id, 'group-b'); assert.deepEqual(calls.map(({ method, path }) => `${method} ${path}`), [ 'GET /api/v2/workspaces/group/group-b', 'PATCH /api/groups/setActive', - 'GET /api/v2/bootstrap', 'GET /api/v2/workspaces/group/group-b', + 'GET /api/v2/scope', 'GET /api/v2/workspaces/group/group-b', ]); assert.deepEqual(calls[1].body, { groupId: 'group-b' }); assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'group-b'); }); + await run('activation patches only the active scope, read outside the HTTP cache', async () => { + const before = useBootstrapStore.getState().data; + before.features = { enable_example: true }; + before.scope.groups = [{ id: 'group-a', name: 'A' }, { id: 'group-b', name: 'B' }]; + const result = await useGroupWorkspaceStore.getState().activate('group-b', () => true); + assert.equal(result.status, 'activated'); + assert.equal(calls.some((call) => call.path === '/api/v2/bootstrap'), false); + const scopeRead = calls.find((call) => call.path === '/api/v2/scope'); + assert.equal(scopeRead.cache, 'no-store'); + const after = useBootstrapStore.getState().data; + assert.equal(after.scope.active_group_id, 'group-b'); + assert.equal(after.scope.active_group_name, 'Name group-b'); + assert.equal(after.features, before.features); + assert.equal(after.scope.groups, before.scope.groups); + }); + await run('a full bootstrap read issued before a switch cannot restore the previous group', async () => { + const stale = deferred(); + const entered = deferred(); + handler = (call) => { + if (call.path === '/api/v2/bootstrap') { + entered.resolve(); + return stale.promise; + } + return defaultResponse(call); + }; + // A tab refocus starts a full read built while group-a is still the saved group. + const background = useBootstrapStore.getState().refresh(); + await entered.promise; + assert.equal(calls[0].cache, 'no-store'); + const result = await useGroupWorkspaceStore.getState().activate('group-b', () => true); + assert.equal(result.status, 'activated'); + const landed = bootstrap('viewer', 'group-a'); + landed.features = { enable_late: true }; + stale.resolve(json(landed)); + await background; + const data = useBootstrapStore.getState().data; + assert.equal(data.scope.active_group_id, 'group-b'); + assert.deepEqual(data.features, { enable_late: true }); + handler = defaultResponse; + await useBootstrapStore.getState().refresh(); + assert.equal(useBootstrapStore.getState().data.scope.active_group_id, 'group-b'); + }); await run('cancelled draft guard performs no request and preserves prior context', async () => { const previous = await useGroupWorkspaceStore.getState().load('group-a'); calls = []; @@ -277,7 +335,7 @@ try { assert.equal(useGroupWorkspaceStore.getState().context, previous); assert.equal(useGroupWorkspaceStore.getState().needsReconciliation, false); assert.equal(activeGroup, 'group-a'); - assert.equal(calls.some((call) => call.path === '/api/v2/bootstrap'), false); + assert.equal(calls.some((call) => /^\/api\/v2\/(bootstrap|scope)$/.test(call.path)), false); }); } await run('lost write acknowledgement requires read-only reconciliation, never a replay', async () => { @@ -300,7 +358,7 @@ try { assert.equal(useGroupWorkspaceStore.getState().needsReconciliation, false); }); await run('failed mandatory refresh cannot reuse cached bootstrap authorization', async () => { - handler = (call) => call.path === '/api/v2/bootstrap' + handler = (call) => call.path === '/api/v2/scope' ? json({ error: 'unavailable' }, 503) : defaultResponse(call); const switching = useGroupWorkspaceStore.getState().activate('group-b', () => true); await assert.rejects(switching, /could not be confirmed/); @@ -324,7 +382,7 @@ try { }); await run('a competing tab selection is reported rather than overwritten or hidden', async () => { handler = (call) => { - if (call.path === '/api/v2/bootstrap') activeGroup = 'group-c'; + if (call.path === '/api/v2/scope') activeGroup = 'group-c'; return defaultResponse(call); }; const switching = useGroupWorkspaceStore.getState().activate('group-b', () => true); @@ -375,7 +433,7 @@ try { const refresh = deferred(); const entered = deferred(); handler = (call) => { - if (call.path === '/api/v2/bootstrap') { + if (call.path === '/api/v2/scope') { entered.resolve(); return refresh.promise; } @@ -385,7 +443,7 @@ try { const rejected = assert.rejects(switching, WorkspaceRequestSuperseded); await entered.promise; useBootstrapStore.setState({ data: bootstrap('other', 'group-c') }); - refresh.resolve(json(bootstrap('viewer', 'group-b'))); + refresh.resolve(json(activeScope('viewer', 'group-b'))); await rejected; assert.equal(useBootstrapStore.getState().data.user.id, 'other'); assert.equal(useGroupWorkspaceStore.getState().context, null); @@ -415,7 +473,7 @@ try { const result = await useGroupWorkspaceStore.getState().reconcile(); assert.equal(result, null); assert.equal(useGroupWorkspaceStore.getState().needsReconciliation, false); - assert.deepEqual(calls.map((call) => call.path), ['/api/v2/bootstrap']); + assert.deepEqual(calls.map((call) => call.path), ['/api/v2/scope']); }); await run('native delegation remains navigable without claiming full action authoring is enabled', async () => { const value = context('group-a'); diff --git a/functional_tests/test_v2_scope_and_bootstrap_timings.py b/functional_tests/test_v2_scope_and_bootstrap_timings.py new file mode 100644 index 000000000..1408c0d7f --- /dev/null +++ b/functional_tests/test_v2_scope_and_bootstrap_timings.py @@ -0,0 +1,197 @@ +# test_v2_scope_and_bootstrap_timings.py +""" +Functional contracts for the lightweight V2 active-scope read and phase timings. + +Version: 0.261.305 +Implemented in: 0.261.305 + +The real route bodies, scope helper, timer, and authentication decorators execute +against isolated storage seams, without importing Azure client initialization. +This validates request behavior, not live Azure latency or cold-import safety. +""" + +import ast +import logging +import time +from functools import wraps +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from flask import Blueprint, Flask, jsonify, request, session + +from test_support.agent_delegation import APP_ROOT, execute_functions +from test_support.versioning import assert_app_version_at_least + + +@pytest.fixture +def environment(): + groups = [{"id": "group-a", "name": "Group A"}] + preferences = { + "activeGroupOid": "group-a", "activePublicWorkspaceOid": "public-a", + "publicDirectorySettings": {"public-a": True, "hidden": False}, + "private_key": "fixture-private-settings", + } + logs = Mock() + app = Flask("scope_and_timings") + app.config.update(TESTING=True, SECRET_KEY="fixture-only-signing") + bp = Blueprint("backend_v2", __name__) + namespace = { + "__name__": __name__, "bp": bp, "jsonify": jsonify, "request": request, + "session": session, "wraps": wraps, "logging": logging, "time": time, + "VERSION": "0.261.305", "log_event": logs, "debug_print": Mock(), + "logger": logging.getLogger(__name__), + "get_auth_security": lambda: [{"sessionAuth": []}], + "swagger_route": lambda **kwargs: lambda function: function, + "check_user_access_status": Mock(return_value=(True, None)), + "get_settings": Mock(return_value={"private_key": "fixture-private-settings"}), + "sanitize_settings_for_user": Mock(return_value={"enable_group_workspaces": True}), + "get_user_settings": Mock(side_effect=lambda user_id: {"settings": preferences}), + "get_current_user_info": lambda: {"userId": session.get("user", {}).get("oid")}, + "get_user_groups": Mock(side_effect=lambda user_id: groups), + "find_group_by_id": Mock(side_effect=lambda group_id: next( + (group for group in groups if group["id"] == group_id), None, + )), + "find_public_workspace_by_id": Mock(side_effect=lambda workspace_id: + {"id": workspace_id, "name": "Public A"} + if workspace_id == "public-a" else None), + "get_all_public_workspaces": Mock(return_value=[{"id": "public-a"}]), + "get_user_visible_public_workspace_ids_from_settings": Mock(return_value=["public-a"]), + "build_accessible_agent_catalog": Mock(return_value=[]), + "_is_chat_agent_allowed_by_governance": Mock(return_value=True), + "_build_chat_model_catalog": Mock(return_value=[]), + "_build_chat_prompt_catalog": Mock(return_value=[]), + "_build_initial_chat_model_selection": Mock(return_value=None), + "build_workspace_section_availability": Mock(return_value={"enabled": False, "sections": {}}), + "count_pending_safety_warnings": Mock(return_value=0), + "ADMIN_NAV": [], + } + for name in ( + "is_chat_file_upload_enabled_for_user", "is_user_workflows_enabled_for_user", + "is_workflow_assistant_enabled_for_user", "is_agent_assistant_enabled", + "is_action_assistant_enabled", "is_admin_review_assistant_enabled", + "is_chat_workflow_results_enabled_for_user", "is_source_review_enabled_for_user", + "is_url_access_enabled_for_user", "is_conversation_contents_drawer_enabled", + ): + namespace[name] = Mock(return_value=True) + for name in ( + "_build_branding", "_build_navigation", "_build_feature_flags", "_build_capabilities", + "get_control_center_capabilities", "_build_orchestration", "_build_notices", + "_build_workspace_uploads", + ): + namespace[name] = Mock(return_value={}) + execute_functions("functions_authentication.py", { + "get_current_user_id", "login_required", "user_required", + }, namespace) + execute_functions("functions_public_workspaces.py", { + "visible_public_workspace_ids_from_user_settings", + }, namespace) + tree = ast.parse((APP_ROOT / "route_backend_v2.py").read_text(encoding="utf-8")) + wanted = {"BootstrapPhaseTimer", "_resolve_active_scope", "v2_scope", "v2_bootstrap"} + nodes = [node for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in wanted] + assert {node.name for node in nodes} == wanted + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(APP_ROOT / "route_backend_v2.py"), "exec"), namespace) + app.register_blueprint(bp) + client = app.test_client() + with client.session_transaction() as state: + state["user"] = {"oid": "viewer", "roles": ["User"]} + return SimpleNamespace( + client=client, namespace=namespace, preferences=preferences, groups=groups, logs=logs, + ) + + +def test_scope_is_private_uncached_and_does_not_build_catalogs(environment): + assert_app_version_at_least("0.261.305") + response = environment.client.get("/api/v2/scope") + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store" + assert response.get_json() == { + "user": {"id": "viewer"}, + "scope": { + "active_group_id": "group-a", "active_group_name": "Group A", + "active_public_workspace_id": "public-a", + }, + } + assert "fixture-private-settings" not in response.get_data(as_text=True) + environment.namespace["get_user_settings"].assert_called_once_with("viewer") + for name in ("get_settings", "_build_chat_model_catalog", "_build_chat_prompt_catalog", + "build_accessible_agent_catalog", "sanitize_settings_for_user"): + environment.namespace[name].assert_not_called() + + +@pytest.mark.parametrize("group_id,workspace_id", [ + ("revoked-group", "hidden"), ("group-a", "missing-public"), (None, None), +]) +def test_stale_preferences_are_filtered_and_match_bootstrap(environment, group_id, workspace_id): + environment.preferences.update(activeGroupOid=group_id, activePublicWorkspaceOid=workspace_id) + scope_response = environment.client.get("/api/v2/scope") + bootstrap_response = environment.client.get("/api/v2/bootstrap") + assert scope_response.status_code == bootstrap_response.status_code == 200 + active = scope_response.get_json()["scope"] + full = bootstrap_response.get_json()["scope"] + assert active == {key: full[key] for key in active} + assert active["active_group_id"] == ("group-a" if group_id == "group-a" else None) + assert active["active_public_workspace_id"] is None + + +def test_deleted_active_group_is_not_echoed(environment): + environment.namespace["find_group_by_id"].return_value = None + environment.namespace["find_group_by_id"].side_effect = None + response = environment.client.get("/api/v2/scope") + assert response.status_code == 200 + assert response.get_json()["scope"]["active_group_id"] is None + + +def test_scope_failures_are_explicit_and_do_not_expose_provider_errors(environment): + environment.namespace["get_user_groups"].side_effect = RuntimeError("fixture-private-provider-error") + response = environment.client.get("/api/v2/scope") + assert response.status_code == 503 + assert response.headers["Cache-Control"] == "no-store" + assert response.get_json() == {"error": "Failed to load active scope"} + assert environment.logs.call_args.kwargs["extra"] == {"error_type": "RuntimeError"} + + +def test_scope_requires_a_signed_in_user(environment): + with environment.client.session_transaction() as state: + state.clear() + response = environment.client.get("/api/v2/scope") + assert response.status_code == 401 + environment.namespace["get_user_settings"].assert_not_called() + + +@pytest.mark.parametrize("fails", [False, True]) +def test_bootstrap_emits_one_flat_numeric_timing_event_even_on_failure(environment, fails): + if fails: + environment.namespace["get_settings"].side_effect = RuntimeError("fixture failure") + response = environment.client.get("/api/v2/bootstrap") + assert response.status_code == (500 if fails else 200) + timings = [call for call in environment.logs.call_args_list + if call.args[0] == "[V2_BOOTSTRAP] Phase timings"] + assert len(timings) == 1 + properties = timings[0].kwargs["extra"] + assert properties["total_ms"] >= 0 + assert all(isinstance(value, (int, float)) and value >= 0 for value in properties.values()) + if not fails: + assert {"phase_settings_ms", "phase_source_review_ms", "phase_model_catalog_ms", + "phase_serialize_ms", "phase_active_scope_ms"} <= properties.keys() + environment.namespace["_build_navigation"].assert_called_once_with( + environment.namespace["get_settings"].return_value, ["User"], + ) + assert response.get_json()["settings"] == {"enable_group_workspaces": True} + + +def test_timer_measures_elapsed_work_and_failed_calls(environment): + ticks = iter([0.0, 0.2, 0.3, 0.7, 0.8]) + timer = environment.namespace["BootstrapPhaseTimer"](clock=lambda: next(ticks)) + timer.mark("settings") + failing = Mock(side_effect=ValueError("fixture failure")) + with pytest.raises(ValueError): + timer.measure("failed_catalog", failing) + timer.emit() + properties = environment.logs.call_args.kwargs["extra"] + assert properties == {"total_ms": 800.0, "phase_settings_ms": 200.0, "phase_failed_catalog_ms": 400.0} + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/fixtures/group_workspace.py b/ui_tests/fixtures/group_workspace.py index 99c34b2fc..b56bab5cc 100644 --- a/ui_tests/fixtures/group_workspace.py +++ b/ui_tests/fixtures/group_workspace.py @@ -1,7 +1,7 @@ # group_workspace.py """ Closed HTTP fixtures for the real V2 group workspace shell. -Version: 0.261.178 +Version: 0.261.305 Implemented in: 0.261.127 Members section in the group context (M7B): 0.261.155 File source credential identifiers modelled as `_prepare_auth_payload` stores them: 0.261.156 @@ -1458,6 +1458,17 @@ def _route(self, route): def _dispatch(self, route, entry): path, method = entry.path, entry.method + if path == "/api/v2/scope" and method == "GET": + group = self.groups.get(self.active_group) + self._json(route, { + "user": {"id": self.viewer_id}, + "scope": { + "active_group_id": self.active_group if group else None, + "active_group_name": group["workspace"]["name"] if group else None, + "active_public_workspace_id": None, + }, + }) + return leak = personal_scope_leak(path, entry.query) if leak: # A group workspace page must never read a personal-scope resource. The M4 group action diff --git a/ui_tests/test_v2_group_workspace_shell.py b/ui_tests/test_v2_group_workspace_shell.py index 49d35a1a5..4b2af4011 100644 --- a/ui_tests/test_v2_group_workspace_shell.py +++ b/ui_tests/test_v2_group_workspace_shell.py @@ -1,7 +1,7 @@ # test_v2_group_workspace_shell.py """ Real-SPA group selection, navigation, scope, and draft safety. -Version: 0.261.271 +Version: 0.261.305 Implemented in: 0.261.127 Group workflow member run/cancel, status gates, delete, and search coverage: 0.261.178 The workflow editor takes focus and hands it back to Edit on close: 0.261.188 @@ -105,7 +105,7 @@ def test_partial_switch_has_explicit_read_only_recovery_without_replaying_patch( ui = group_ui ui.active_group = "group-a" ui.open("/groups/group-a") - ui.reject_next("GET", "/api/v2/bootstrap") + ui.reject_next("GET", "/api/v2/scope") ui.page.get_by_role("combobox", name="Group workspace").select_option("group-b") expect(ui.page.get_by_role("button", name="Refresh workspace selection", exact=True)).to_be_visible() expect(ui.page.get_by_role("combobox", name="Group workspace")).to_be_disabled() @@ -117,6 +117,22 @@ def test_partial_switch_has_explicit_read_only_recovery_without_replaying_patch( assert len([entry for entry in ui.writes if entry.path == "/api/groups/setActive"]) == 1 +@pytest.mark.parametrize("width,height", [(1440, 900), (390, 844)]) +def test_group_switch_uses_scope_confirmation_without_another_bootstrap(group_ui, width, height): + ui = group_ui + ui.active_group = "group-a" + ui.open("/groups/group-a", width=width, height=height) + expect(ui.page.get_by_text("About this group", exact=True)).to_be_visible() + before = len([entry for entry in ui.requests if entry.path == "/api/v2/bootstrap"]) + ui.page.get_by_role("combobox", name="Group workspace").select_option("group-b") + expect(ui.page).to_have_url(f"{ORIGIN}/v2/groups/group-b") + expect(ui.page.get_by_text("Role: Member", exact=True)).to_be_visible() + expect(ui.page.get_by_role("combobox", name="Group workspace")).to_have_value("group-b") + assert len([entry for entry in ui.requests if entry.path == "/api/v2/bootstrap"]) == before + assert len([entry for entry in ui.requests if entry.path == "/api/v2/scope"]) == 1 + assert len([entry for entry in ui.writes if entry.path == "/api/groups/setActive"]) == 1 + ui.assert_no_overflow() + def test_group_delegation_editing_and_navigation_keep_the_correct_scope(group_ui): ui = group_ui ui.open("/groups/group-a/actions") From 33ff7baeb8952f28fd22b99ccd61d1ee4f4deb28 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 8 Oct 2026 18:01:49 -0400 Subject: [PATCH 2/2] Normalize V2 scope response tuple contracts Use response, status, and headers consistently for scope success and failure paths. Add regression coverage for all three return paths, update related documentation, and bump the application patch version to 0.261.306. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- application/single_app/route_backend_v2.py | 3 +-- .../fixes/V2_GROUP_SWITCH_LATENCY_FIX.md | 3 +++ docs/explanation/release_notes.md | 8 ++++++ .../test_v2_scope_and_bootstrap_timings.py | 25 +++++++++++++++++-- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 9d6d17910..bfc800eaf 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.305" +VERSION = "0.261.306" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index b9d8e63d7..8d9d4dea8 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -1263,8 +1263,7 @@ def is_visible_workspace(workspace_id): user_settings_dict, authorized_group_ids, is_visible_workspace ) response = jsonify({"user": {"id": user_id}, "scope": active_scope}) - response.headers["Cache-Control"] = "no-store" - return response, 200 + return response, 200, {"Cache-Control": "no-store"} except Exception as exc: log_event( "[V2_SCOPE] Failed to resolve active scope.", diff --git a/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md b/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md index 9520f7f48..25d061e10 100644 --- a/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md +++ b/docs/explanation/fixes/V2_GROUP_SWITCH_LATENCY_FIX.md @@ -1,6 +1,7 @@ # V2 Group Switch Latency Fix Fixed in version: **0.261.305** +Response contract hardened in version: **0.261.306** Related backend follow-up: [#1725](https://github.com/microsoft/simplechat/issues/1725). The application version is recorded in `application/single_app/config.py`. @@ -35,6 +36,8 @@ selected public workspace. It shares `_resolve_active_scope` with bootstrap, filters unavailable selections, and returns `Cache-Control: no-store`. Storage failures return an explicit 503 rather than an empty successful selection. It sends no application settings or catalog content to the browser. +Success, missing-identity, and storage-failure paths consistently return +`(response, status, headers)`, with unchanged status codes and no-store behavior. The group-switch sequence is now: diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 377a08fbe..6c608129d 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,14 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.306)** + +#### Bug Fixes + +* **Consistent V2 Scope Response Contract** + * Scope confirmation now uses the same response tuple format for successful reads and error paths, resolving the CodeQL mixed-tuple-returns finding without changing status codes, response bodies, or no-store behavior. + * (Ref: `route_backend_v2.py` `v2_scope`, `test_v2_scope_and_bootstrap_timings.py`) + ### **(v0.261.305)** #### Bug Fixes diff --git a/functional_tests/test_v2_scope_and_bootstrap_timings.py b/functional_tests/test_v2_scope_and_bootstrap_timings.py index 1408c0d7f..df87c7004 100644 --- a/functional_tests/test_v2_scope_and_bootstrap_timings.py +++ b/functional_tests/test_v2_scope_and_bootstrap_timings.py @@ -2,7 +2,7 @@ """ Functional contracts for the lightweight V2 active-scope read and phase timings. -Version: 0.261.305 +Version: 0.261.306 Implemented in: 0.261.305 The real route bodies, scope helper, timer, and authentication decorators execute @@ -14,6 +14,7 @@ import logging import time from functools import wraps +from inspect import unwrap from types import SimpleNamespace from unittest.mock import Mock @@ -39,7 +40,7 @@ def environment(): namespace = { "__name__": __name__, "bp": bp, "jsonify": jsonify, "request": request, "session": session, "wraps": wraps, "logging": logging, "time": time, - "VERSION": "0.261.305", "log_event": logs, "debug_print": Mock(), + "VERSION": "0.261.306", "log_event": logs, "debug_print": Mock(), "logger": logging.getLogger(__name__), "get_auth_security": lambda: [{"sessionAuth": []}], "swagger_route": lambda **kwargs: lambda function: function, @@ -160,6 +161,26 @@ def test_scope_requires_a_signed_in_user(environment): environment.namespace["get_user_settings"].assert_not_called() +@pytest.mark.parametrize("outcome,expected_status", [ + ("success", 200), ("missing_identity", 401), ("storage_failure", 503), +]) +def test_scope_body_returns_consistent_response_tuples(environment, outcome, expected_status): + """All scope body return paths preserve response, status, and no-store headers.""" + route = unwrap(environment.namespace["v2_scope"]) + with environment.client.application.test_request_context("/api/v2/scope"): + if outcome != "missing_identity": + session["user"] = {"oid": "viewer", "roles": ["User"]} + if outcome == "storage_failure": + environment.namespace["get_user_groups"].side_effect = RuntimeError("fixture failure") + result = route() + assert isinstance(result, tuple) + assert len(result) == 3 + response, status, headers = result + assert response.is_json + assert status == expected_status + assert headers == {"Cache-Control": "no-store"} + + @pytest.mark.parametrize("fails", [False, True]) def test_bootstrap_emits_one_flat_numeric_timing_event_even_on_failure(environment, fails): if fails: