From 55104a782e365c0b943b56a340919a4ee17e6cbc Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 29 Jul 2026 22:09:15 -0400 Subject: [PATCH] Fix selected public prompt migration --- application/single_app/config.py | 2 +- .../single_app/functions_data_management.py | 79 +++++- .../PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md | 43 +++ docs/explanation/fixes/index.md | 1 + docs/explanation/release_notes.md | 9 + ...data_management_public_prompt_migration.py | 268 ++++++++++++++++++ .../test_data_management_security_patterns.py | 5 +- .../test_file_sync_azure_blob_storage.py | 5 +- .../test_file_sync_azure_files_identity.py | 5 +- .../test_file_sync_onedrive_personal.py | 5 +- 10 files changed, 404 insertions(+), 18 deletions(-) create mode 100644 docs/explanation/fixes/PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md create mode 100644 functional_tests/test_data_management_public_prompt_migration.py diff --git a/application/single_app/config.py b/application/single_app/config.py index a69d7c20c..0296c2451 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.071" +VERSION = "0.250.072" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_data_management.py b/application/single_app/functions_data_management.py index 10049e4ef..30979cc14 100644 --- a/application/single_app/functions_data_management.py +++ b/application/single_app/functions_data_management.py @@ -270,7 +270,7 @@ class DataManagementMigrationCanceledError(RuntimeError): {"name": "public_workspaces", "container_attr": "cosmos_public_workspaces_container", "container_name_attr": "cosmos_public_workspaces_container_name", "partition_key_path": "/id", "id_field": "id"}, {"name": "public_documents", "container_attr": "cosmos_public_documents_container", "container_name_attr": "cosmos_public_documents_container_name", "partition_key_path": "/id", "filter_field": "public_workspace_id", "documents": True}, {"name": "public_workspace_identities", "container_attr": "cosmos_public_workspace_identities_container", "container_name_attr": "cosmos_public_workspace_identities_container_name", "partition_key_path": "/public_workspace_id", "filter_field": "public_workspace_id"}, - {"name": "public_prompts", "container_attr": "cosmos_public_prompts_container", "container_name_attr": "cosmos_public_prompts_container_name", "partition_key_path": "/id", "filter_field": "public_workspace_id"}, + {"name": "public_prompts", "container_attr": "cosmos_public_prompts_container", "container_name_attr": "cosmos_public_prompts_container_name", "partition_key_path": "/id", "filter_fields": ["public_id", "public_workspace_id"]}, ], } @@ -2189,6 +2189,44 @@ def _restore_temporary_destination_capacity( return restore_warnings, migration_state +def _get_selected_scope_filter_fields(container_definition): + """Return the trusted ownership fields used for selected-scope Cosmos reads.""" + configured_fields = container_definition.get("filter_fields") + if configured_fields is None: + configured_fields = [container_definition.get("filter_field")] + elif isinstance(configured_fields, str): + configured_fields = [configured_fields] + elif not isinstance(configured_fields, (list, tuple)): + raise DataManagementSettingsValidationError( + "Migration container filter fields must be a string, list, or tuple." + ) + + filter_fields = [] + for field_name in configured_fields: + if not field_name: + continue + if not isinstance(field_name, str) or not re.fullmatch( + r"[A-Za-z_][A-Za-z0-9_]*", + field_name, + ): + raise DataManagementSettingsValidationError( + "Migration container filter fields must be valid Cosmos property names." + ) + filter_fields.append(field_name) + return filter_fields + + +def _build_selected_scope_filter_clause(filter_fields): + """Build the static Cosmos ownership predicate for one selected scope ID.""" + filter_clauses = [ + f"c.{field_name} = @selected_id" + for field_name in filter_fields + ] + if len(filter_clauses) == 1: + return filter_clauses[0] + return f"({' OR '.join(filter_clauses)})" + + def _iter_selected_cosmos_records( container_definition, selection, @@ -2255,11 +2293,13 @@ def prepare_item(item): yield prepare_item(item) return - filter_field = container_definition.get("filter_field") - if not filter_field: + filter_fields = _get_selected_scope_filter_fields(container_definition) + if not filter_fields: return + filter_clause = _build_selected_scope_filter_clause(filter_fields) + seen_identities = set() if len(filter_fields) > 1 else None for selected_id in ids: - query = f"SELECT * FROM c WHERE c.{filter_field} = @selected_id" + query = f"SELECT * FROM c WHERE {filter_clause}" parameters = [{"name": "@selected_id", "value": selected_id}] if source_start_epoch is not None: query += " AND c._ts >= @source_start_epoch" @@ -2272,6 +2312,15 @@ def prepare_item(item): parameters=parameters, enable_cross_partition_query=True, ): + if seen_identities is not None: + item_identity = _get_cosmos_document_identity( + item, + container_definition["partition_key_path"], + ) + if item_identity and item_identity in seen_identities: + continue + if item_identity: + seen_identities.add(item_identity) yield prepare_item(item) @@ -4728,15 +4777,27 @@ def _iter_target_cosmos_records(target_container, container_definition, selectio except (CosmosResourceNotFoundError, ResourceNotFoundError): continue return - filter_field = container_definition.get("filter_field") - if not filter_field: + filter_fields = _get_selected_scope_filter_fields(container_definition) + if not filter_fields: return + filter_clause = _build_selected_scope_filter_clause(filter_fields) + seen_identities = set() if len(filter_fields) > 1 else None for selected_id in selection.get("ids") or []: - yield from target_container.query_items( - query=f"SELECT * FROM c WHERE c.{filter_field} = @selected_id", + for item in target_container.query_items( + query=f"SELECT * FROM c WHERE {filter_clause}", parameters=[{"name": "@selected_id", "value": selected_id}], enable_cross_partition_query=True, - ) + ): + if seen_identities is not None: + item_identity = _get_cosmos_document_identity( + item, + container_definition["partition_key_path"], + ) + if item_identity and item_identity in seen_identities: + continue + if item_identity: + seen_identities.add(item_identity) + yield item def _delete_target_cosmos_record(target_container, document, partition_key_path): diff --git a/docs/explanation/fixes/PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md b/docs/explanation/fixes/PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md new file mode 100644 index 000000000..9eca5c9b0 --- /dev/null +++ b/docs/explanation/fixes/PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md @@ -0,0 +1,43 @@ +# Public Workspace Prompt Migration Fix (v0.250.072) + +Fixed in version: **0.250.072** + +Related issue: [#1033](https://github.com/microsoft/simplechat/issues/1033) + +Related config.py version update: `VERSION = "0.250.072"` + +## Issue Description + +Selected public-workspace Data Management migrations omitted current public prompt records. Current prompts are stored in `public_prompts` with their workspace ownership in `public_id`, while selected-scope migration used only the legacy `public_workspace_id` ownership field. + +## Root Cause Analysis + +The selected-scope Cosmos iterator generated one equality filter from each container definition's `filter_field`. The `public_prompts` definition still named `public_workspace_id`, so current prompts with only `public_id` never reached the migration copy path. All-workspaces migration did not use that selected-scope filter and was unaffected. + +## Technical Details + +### Files Modified + +- `application/single_app/functions_data_management.py` +- `application/single_app/config.py` +- `functional_tests/test_data_management_public_prompt_migration.py` +- Data Management and version-contract functional tests +- `docs/explanation/release_notes.md` + +### Code Changes Summary + +- Added a compatibility-aware `filter_fields` contract for selected-scope Cosmos migration definitions while preserving existing single `filter_field` definitions. +- Configured `public_prompts` to match current `public_id` ownership first and legacy `public_workspace_id` ownership second. +- Builds a parameterized OR predicate for selected public-workspace prompt reads and deduplicates transitional records that contain both ownership fields by Cosmos document identity. +- Applied the same selected-scope matching and deduplication to destination reconciliation scans. +- Left all-workspaces migration on its existing unfiltered container-read path. + +## Impact Analysis + +Selected migrations now copy current and legacy prompts owned by selected public workspaces, exclude prompts owned by unselected workspaces, and count each copied prompt once. Existing user, group, and all-workspace migration behavior remains unchanged. + +## Validation + +- Focused regression coverage verifies current, legacy, transitional, unselected, and all-workspace public-prompt cases. +- The focused test asserts source-read and copied artifact counts for selected and all-workspace migrations. +- Relevant Data Management Cosmos migration, reconciliation, orchestration, and security/version checks are run with this change. \ No newline at end of file diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index 782474a3b..d82e04a07 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -6,6 +6,7 @@ order: 120 category: Version History --- +- [Public Workspace Prompt Migration Fix](PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md) - [Azure OpenAI Model Discovery Identity Fix](v0.250.001/AZURE_OPENAI_MODEL_DISCOVERY_IDENTITY_FIX.md) - [CosmosClient Import Binding CodeQL Fix](COSMOSCLIENT_IMPORT_BINDING_CODEQL_FIX.md) - [Conversation Cache Invalidation Authorization Fix](CONVERSATION_CACHE_INVALIDATION_AUTHORIZATION_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 7b5d298ae..a93e355c2 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.072)** + +#### Bug Fixes + +* **Selected Public Workspace Prompt Migration** + * Selected public-workspace Data Management migrations now copy current prompts owned through `public_id` while retaining compatibility with legacy `public_workspace_id` records. + * Prompts outside the selected workspaces remain excluded, transitional records migrate once, and copied prompt artifact counts are accurate. All-workspaces migration behavior is unchanged. + * (Ref: microsoft/simplechat#1033, `functions_data_management.py`, `test_data_management_public_prompt_migration.py`) + ### **(v0.250.071)** #### New Features diff --git a/functional_tests/test_data_management_public_prompt_migration.py b/functional_tests/test_data_management_public_prompt_migration.py new file mode 100644 index 000000000..90c31bd7c --- /dev/null +++ b/functional_tests/test_data_management_public_prompt_migration.py @@ -0,0 +1,268 @@ +# test_data_management_public_prompt_migration.py +""" +Functional test for selected public workspace prompt migration. +Version: 0.250.072 +Implemented in: 0.250.072 + +This test ensures selected public workspace migrations copy current public_id +prompts, preserve legacy ownership compatibility, exclude unselected prompts, +and retain all-workspaces migration behavior. +""" + +import copy +import importlib.util +from pathlib import Path +import sys +import types + +from azure.core.exceptions import ResourceNotFoundError + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +MODULE_PATH = APP_ROOT / "functions_data_management.py" +sys.path.insert(0, str(APP_ROOT)) + +# Local application modules require the app source path during standalone test runs. +from functions_data_management_migration_state import initialize_migration_state +from functions_migration_provenance import create_migration_provenance_context + + +class FakeJobContainer: + """Provide job and manifest persistence required by the migration helpers.""" + + def __init__(self): + self.manifest_batches = [] + + def upsert_item(self, body): + return copy.deepcopy(body) + + def create_item(self, body): + self.manifest_batches.append(copy.deepcopy(body)) + return copy.deepcopy(body) + + +class FakePromptContainer: + """Emulate selected public-prompt Cosmos queries and target writes.""" + + def __init__(self, documents): + self.documents = { + document["id"]: copy.deepcopy(document) + for document in documents + } + self.created = [] + self.queries = [] + + def query_items(self, **kwargs): + self.queries.append(copy.deepcopy(kwargs)) + selected_id = next( + ( + parameter["value"] + for parameter in kwargs.get("parameters") or [] + if parameter["name"] == "@selected_id" + ), + None, + ) + query = kwargs.get("query") or "" + filter_fields = [ + field_name + for field_name in ("public_id", "public_workspace_id") + if f"c.{field_name} = @selected_id" in query + ] + documents = list(self.documents.values()) + if selected_id is not None and filter_fields: + documents = [ + document + for document in documents + if any(document.get(field_name) == selected_id for field_name in filter_fields) + ] + parameters = { + parameter["name"]: parameter["value"] + for parameter in kwargs.get("parameters") or [] + } + if "@source_start_epoch" in parameters: + documents = [ + document for document in documents + if document.get("_ts", 0) >= parameters["@source_start_epoch"] + ] + if "@source_cutoff_epoch" in parameters: + documents = [ + document for document in documents + if document.get("_ts", 0) <= parameters["@source_cutoff_epoch"] + ] + return iter(copy.deepcopy(documents)) + + def read_item(self, item, partition_key, **_kwargs): + if item != partition_key or item not in self.documents: + raise ResourceNotFoundError("not found") + return copy.deepcopy(self.documents[item]) + + def create_item(self, document, response_hook=None, **_kwargs): + if document["id"] in self.documents: + conflict = RuntimeError("conflict") + conflict.status_code = 409 + raise conflict + if response_hook: + response_hook({"x-ms-request-charge": "1.0"}, document) + stored_document = copy.deepcopy(document) + self.documents[stored_document["id"]] = stored_document + self.created.append(stored_document) + return copy.deepcopy(stored_document) + + +class FakeTargetDatabase: + """Return the isolated prompt target for the migration resource.""" + + def __init__(self, target_container): + self.target_container = target_container + + def create_container_if_not_exists(self, **_kwargs): + return self.target_container + + +def load_data_management_module(monkeypatch, source_container, job_container): + """Load the production migration module with focused Cosmos dependencies.""" + config_module = types.ModuleType("config") + config_module.CLIENTS = {} + config_module.VERSION = "0.250.072" + config_module.cosmos_data_management_jobs_container = job_container + config_module.cosmos_data_management_job_items_container = job_container + config_module.cosmos_settings_container = job_container + config_module.cosmos_public_prompts_container = source_container + config_module.cosmos_public_prompts_container_name = "public-prompts" + monkeypatch.setitem(sys.modules, "config", config_module) + + appinsights_module = types.ModuleType("functions_appinsights") + appinsights_module.log_event = lambda *_args, **_kwargs: None + monkeypatch.setitem(sys.modules, "functions_appinsights", appinsights_module) + + throughput_module = types.ModuleType("functions_cosmos_throughput") + throughput_module.CosmosThroughputError = RuntimeError + throughput_module.get_container_throughput = lambda *_args, **_kwargs: {} + throughput_module.get_database_throughput = lambda *_args, **_kwargs: {} + throughput_module.set_database_throughput = lambda *_args, **_kwargs: {} + monkeypatch.setitem(sys.modules, "functions_cosmos_throughput", throughput_module) + + module_name = "data_management_public_prompt_migration_test_module" + spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + assert spec and spec.loader + spec.loader.exec_module(module) + monkeypatch.delitem(sys.modules, module_name, raising=False) + return module + + +def copy_public_prompts(module, source_container, selection): + """Run the production Cosmos copy path for only the public prompt resource.""" + prompt_definition = next( + definition + for definition in module.DATA_MANAGEMENT_MIGRATION_COSMOS_CONTAINERS["public_workspaces"] + if definition["name"] == "public_prompts" + ) + module.DATA_MANAGEMENT_MIGRATION_COSMOS_CONTAINERS = { + "users": [], + "groups": [], + "public_workspaces": [prompt_definition], + } + target_container = FakePromptContainer([]) + migration_id = "11111111-1111-1111-1111-111111111111" + migration_state = initialize_migration_state( + None, + migration_id, + {"test": "public-prompt-migration"}, + ) + job = {"id": migration_id, "migration_state": migration_state} + artifacts = module._copy_cosmos_records_to_target( + FakeTargetDatabase(target_container), + "public_workspaces", + selection, + job, + migration_state, + create_migration_provenance_context(migration_id=migration_id), + { + "migration_max_parallel_operations": 1, + "migration_retry_count": 1, + "data_management_job_lease_seconds": 900, + }, + ) + return prompt_definition, target_container, artifacts + + +def test_selected_public_prompt_migration_supports_current_and_legacy_ownership(monkeypatch): + """Copy selected current and legacy prompts exactly once while excluding others.""" + source_documents = [ + {"id": "current-selected", "public_id": "public-a", "_ts": 1}, + {"id": "legacy-selected", "public_workspace_id": "public-a", "_ts": 1}, + { + "id": "transitional-selected", + "public_id": "public-a", + "public_workspace_id": "public-a", + "_ts": 1, + }, + {"id": "current-unselected", "public_id": "public-b", "_ts": 1}, + {"id": "legacy-unselected", "public_workspace_id": "public-b", "_ts": 1}, + ] + source_container = FakePromptContainer(source_documents) + module = load_data_management_module(monkeypatch, source_container, FakeJobContainer()) + selected_scope = { + "mode": "selected", + "ids": ["public-a"], + "include_documents": False, + } + + prompt_definition, target_container, artifacts = copy_public_prompts( + module, + source_container, + selected_scope, + ) + + assert prompt_definition["filter_fields"] == ["public_id", "public_workspace_id"] + assert {document["id"] for document in target_container.created} == { + "current-selected", + "legacy-selected", + "transitional-selected", + } + assert len(target_container.created) == 3 + assert artifacts[0]["source_read_count"] == 3 + assert artifacts[0]["copied_count"] == 3 + assert artifacts[0]["created_count"] == 3 + assert artifacts[0]["failed_count"] == 0 + assert "c.public_id = @selected_id" in source_container.queries[0]["query"] + assert "c.public_workspace_id = @selected_id" in source_container.queries[0]["query"] + + +def test_all_public_prompt_migration_remains_unfiltered(monkeypatch): + """Copy every prompt for all-workspaces migration without selected-scope filtering.""" + source_documents = [ + {"id": "current-selected", "public_id": "public-a", "_ts": 1}, + {"id": "legacy-selected", "public_workspace_id": "public-a", "_ts": 1}, + { + "id": "transitional-selected", + "public_id": "public-a", + "public_workspace_id": "public-a", + "_ts": 1, + }, + {"id": "current-unselected", "public_id": "public-b", "_ts": 1}, + {"id": "legacy-unselected", "public_workspace_id": "public-b", "_ts": 1}, + ] + source_container = FakePromptContainer(source_documents) + module = load_data_management_module(monkeypatch, source_container, FakeJobContainer()) + all_scope = { + "mode": "all", + "ids": [], + "include_documents": False, + } + + _prompt_definition, target_container, artifacts = copy_public_prompts( + module, + source_container, + all_scope, + ) + + assert {document["id"] for document in target_container.created} == { + document["id"] for document in source_documents + } + assert artifacts[0]["source_read_count"] == len(source_documents) + assert artifacts[0]["copied_count"] == len(source_documents) + assert "c.public_id = @selected_id" not in source_container.queries[0]["query"] + assert "c.public_workspace_id = @selected_id" not in source_container.queries[0]["query"] diff --git a/functional_tests/test_data_management_security_patterns.py b/functional_tests/test_data_management_security_patterns.py index 501f284d5..6962996c8 100644 --- a/functional_tests/test_data_management_security_patterns.py +++ b/functional_tests/test_data_management_security_patterns.py @@ -2,9 +2,10 @@ # test_data_management_security_patterns.py """ Functional test for Data Management security patterns. -Version: 0.250.071 +Version: 0.250.072 Implemented in: 0.241.211 Updated in: 0.250.076 +Updated in: 0.250.072 This test ensures Data Management admin routes require authenticated admin access, secrets stay redacted in frontend responses, and the admin browser @@ -68,7 +69,7 @@ def test_version_and_container_registration(): """Validate the Data Management version and Cosmos job container registrations.""" config_source = read_text(CONFIG_FILE) - assert 'VERSION = "0.250.071"' in config_source + assert 'VERSION = "0.250.072"' in config_source assert 'cosmos_data_management_jobs_container_name = "data_management_jobs"' in config_source assert 'partition_key=PartitionKey(path="/id")' in config_source assert 'cosmos_data_management_job_items_container_name = "data_management_job_items"' in config_source diff --git a/functional_tests/test_file_sync_azure_blob_storage.py b/functional_tests/test_file_sync_azure_blob_storage.py index ef0a1292f..4c3857168 100644 --- a/functional_tests/test_file_sync_azure_blob_storage.py +++ b/functional_tests/test_file_sync_azure_blob_storage.py @@ -2,11 +2,12 @@ # test_file_sync_azure_blob_storage.py """ Functional test for Azure Blob Storage File Sync. -Version: 0.250.070 +Version: 0.250.072 Implemented in: 0.250.067 Security hardening in: 0.250.068 Container SAS support in: 0.250.069 Non-Key-Vault and List/Read validation fix in: 0.250.070 +Updated in: 0.250.072 This test ensures Azure Blob Storage is wired into the shared File Sync pipeline for every supported workspace scope without requiring live Azure @@ -85,7 +86,7 @@ def test_version_and_dependency_pin(): config_text = read_text("application/single_app/config.py") requirements_text = read_text("application/single_app/requirements.txt") - assert 'VERSION = "0.250.071"' in config_text + assert 'VERSION = "0.250.072"' in config_text assert "azure-storage-blob==12.24.1" in requirements_text diff --git a/functional_tests/test_file_sync_azure_files_identity.py b/functional_tests/test_file_sync_azure_files_identity.py index c89db0796..2583353ab 100644 --- a/functional_tests/test_file_sync_azure_files_identity.py +++ b/functional_tests/test_file_sync_azure_files_identity.py @@ -2,12 +2,13 @@ # test_file_sync_azure_files_identity.py """ Functional test for Azure Files File Sync identity support. -Version: 0.250.070 +Version: 0.250.072 Implemented in: 0.241.127 Updated in: 0.250.067 Updated in: 0.250.068 Updated in: 0.250.069 Updated in: 0.250.070 +Updated in: 0.250.072 This test ensures Azure Files sync sources are wired to managed identity, service principal, and connection string authentication without requiring live @@ -43,7 +44,7 @@ def test_version_and_dependency_pin(): config_text = read_text("application/single_app/config.py") requirements_text = read_text("application/single_app/requirements.txt") - assert 'VERSION = "0.250.071"' in config_text + assert 'VERSION = "0.250.072"' in config_text assert "azure-storage-file-share==12.25.0" in requirements_text diff --git a/functional_tests/test_file_sync_onedrive_personal.py b/functional_tests/test_file_sync_onedrive_personal.py index 96d153c6c..4b1fa043d 100644 --- a/functional_tests/test_file_sync_onedrive_personal.py +++ b/functional_tests/test_file_sync_onedrive_personal.py @@ -2,12 +2,13 @@ # test_file_sync_onedrive_personal.py """ Functional test for personal OneDrive File Sync support. -Version: 0.250.070 +Version: 0.250.072 Implemented in: 0.241.128 Updated in: 0.250.067 Updated in: 0.250.068 Updated in: 0.250.069 Updated in: 0.250.070 +Updated in: 0.250.072 This test ensures OneDrive sync source code remains wired as personal-only File Sync support while the admin source-type control keeps OneDrive marked as coming @@ -43,7 +44,7 @@ def test_version_and_source_defaults(): settings_text = read_text("application/single_app/functions_settings.py") file_sync_text = read_text("application/single_app/functions_file_sync.py") - assert 'VERSION = "0.250.071"' in config_text + assert 'VERSION = "0.250.072"' in config_text assert "FILE_SYNC_SOURCE_TYPE_ONEDRIVE = \"onedrive\"" in file_sync_text assert "FILE_SYNC_SOURCE_TYPE_ONEDRIVE" in file_sync_text assert "FILE_SYNC_SOURCE_TYPE_ONEDRIVE: {\"client_secret\"}" in file_sync_text