@salesforce/afv-skills 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/activating-datacloud/SKILL.md +0 -1
- package/skills/analyzing-omnistudio-dependencies/SKILL.md +0 -1
- package/skills/applying-slds/SKILL.md +322 -0
- package/skills/applying-slds/checklists.md +83 -0
- package/skills/applying-slds/examples.md +283 -0
- package/skills/applying-slds/guidance/README.md +83 -0
- package/skills/applying-slds/guidance/blueprints-index.md +213 -0
- package/skills/applying-slds/guidance/icons-guidance.md +186 -0
- package/skills/applying-slds/guidance/overviews/borders.md +236 -0
- package/skills/applying-slds/guidance/overviews/color.md +266 -0
- package/skills/applying-slds/guidance/overviews/display-density.md +366 -0
- package/skills/applying-slds/guidance/overviews/icons.md +240 -0
- package/skills/applying-slds/guidance/overviews/illustrations.md +235 -0
- package/skills/applying-slds/guidance/overviews/shadows.md +176 -0
- package/skills/applying-slds/guidance/overviews/spacing.md +216 -0
- package/skills/applying-slds/guidance/overviews/typography.md +323 -0
- package/skills/applying-slds/guidance/overviews/utilities.md +542 -0
- package/skills/applying-slds/guidance/slds-development-guide.md +288 -0
- package/skills/applying-slds/guidance/styling-hooks/borders.md +202 -0
- package/skills/applying-slds/guidance/styling-hooks/color/expressive-palette-hooks.md +153 -0
- package/skills/applying-slds/guidance/styling-hooks/color/index.md +171 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/accent-hooks.md +204 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/feedback-hooks.md +768 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/surface-hooks.md +337 -0
- package/skills/applying-slds/guidance/styling-hooks/color/system-hooks.md +132 -0
- package/skills/applying-slds/guidance/styling-hooks/index.md +327 -0
- package/skills/applying-slds/guidance/styling-hooks/shadows.md +238 -0
- package/skills/applying-slds/guidance/styling-hooks/spacing.md +254 -0
- package/skills/applying-slds/guidance/styling-hooks/typography.md +448 -0
- package/skills/applying-slds/guidance/utilities/alignment.md +119 -0
- package/skills/applying-slds/guidance/utilities/borders.md +131 -0
- package/skills/applying-slds/guidance/utilities/box.md +125 -0
- package/skills/applying-slds/guidance/utilities/color.md +165 -0
- package/skills/applying-slds/guidance/utilities/dark-mode.md +111 -0
- package/skills/applying-slds/guidance/utilities/description-list.md +168 -0
- package/skills/applying-slds/guidance/utilities/floats.md +117 -0
- package/skills/applying-slds/guidance/utilities/grid.md +264 -0
- package/skills/applying-slds/guidance/utilities/horizontal-list.md +110 -0
- package/skills/applying-slds/guidance/utilities/hyphenation.md +84 -0
- package/skills/applying-slds/guidance/utilities/index.md +205 -0
- package/skills/applying-slds/guidance/utilities/interactions.md +89 -0
- package/skills/applying-slds/guidance/utilities/layout.md +109 -0
- package/skills/applying-slds/guidance/utilities/line-clamp.md +131 -0
- package/skills/applying-slds/guidance/utilities/margin.md +155 -0
- package/skills/applying-slds/guidance/utilities/media-object.md +161 -0
- package/skills/applying-slds/guidance/utilities/name-value-list.md +152 -0
- package/skills/applying-slds/guidance/utilities/padding.md +155 -0
- package/skills/applying-slds/guidance/utilities/position.md +177 -0
- package/skills/applying-slds/guidance/utilities/print.md +114 -0
- package/skills/applying-slds/guidance/utilities/scrollable.md +126 -0
- package/skills/applying-slds/guidance/utilities/sizing.md +190 -0
- package/skills/applying-slds/guidance/utilities/themes.md +121 -0
- package/skills/applying-slds/guidance/utilities/truncate.md +127 -0
- package/skills/applying-slds/guidance/utilities/typography.md +166 -0
- package/skills/applying-slds/guidance/utilities/vertical-list.md +166 -0
- package/skills/applying-slds/guidance/utilities/visibility.md +228 -0
- package/skills/applying-slds/metadata/README.md +84 -0
- package/skills/applying-slds/metadata/blueprints/components/accordion.yaml +304 -0
- package/skills/applying-slds/metadata/blueprints/components/activity-timeline.yaml +92 -0
- package/skills/applying-slds/metadata/blueprints/components/alert.yaml +103 -0
- package/skills/applying-slds/metadata/blueprints/components/app-launcher.yaml +94 -0
- package/skills/applying-slds/metadata/blueprints/components/avatar-group.yaml +81 -0
- package/skills/applying-slds/metadata/blueprints/components/avatar.yaml +97 -0
- package/skills/applying-slds/metadata/blueprints/components/badges.yaml +102 -0
- package/skills/applying-slds/metadata/blueprints/components/brand-band.yaml +198 -0
- package/skills/applying-slds/metadata/blueprints/components/breadcrumbs.yaml +95 -0
- package/skills/applying-slds/metadata/blueprints/components/builder-header.yaml +192 -0
- package/skills/applying-slds/metadata/blueprints/components/button-groups.yaml +82 -0
- package/skills/applying-slds/metadata/blueprints/components/button-icons.yaml +295 -0
- package/skills/applying-slds/metadata/blueprints/components/buttons.yaml +230 -0
- package/skills/applying-slds/metadata/blueprints/components/cards.yaml +124 -0
- package/skills/applying-slds/metadata/blueprints/components/carousel.yaml +140 -0
- package/skills/applying-slds/metadata/blueprints/components/chat.yaml +179 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-button-group.yaml +192 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-button.yaml +204 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-toggle.yaml +177 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox.yaml +108 -0
- package/skills/applying-slds/metadata/blueprints/components/color-picker.yaml +172 -0
- package/skills/applying-slds/metadata/blueprints/components/combobox.yaml +136 -0
- package/skills/applying-slds/metadata/blueprints/components/counter.yaml +147 -0
- package/skills/applying-slds/metadata/blueprints/components/data-tables.yaml +157 -0
- package/skills/applying-slds/metadata/blueprints/components/datepickers.yaml +130 -0
- package/skills/applying-slds/metadata/blueprints/components/datetime-picker.yaml +155 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-composer.yaml +201 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-form-footer.yaml +161 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-utility-bar.yaml +175 -0
- package/skills/applying-slds/metadata/blueprints/components/drop-zone.yaml +115 -0
- package/skills/applying-slds/metadata/blueprints/components/dueling-picklist.yaml +196 -0
- package/skills/applying-slds/metadata/blueprints/components/dynamic-icons.yaml +128 -0
- package/skills/applying-slds/metadata/blueprints/components/dynamic-menu.yaml +141 -0
- package/skills/applying-slds/metadata/blueprints/components/expandable-section.yaml +115 -0
- package/skills/applying-slds/metadata/blueprints/components/expression.yaml +143 -0
- package/skills/applying-slds/metadata/blueprints/components/feeds.yaml +125 -0
- package/skills/applying-slds/metadata/blueprints/components/file-selector.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/files.yaml +119 -0
- package/skills/applying-slds/metadata/blueprints/components/form-element.yaml +145 -0
- package/skills/applying-slds/metadata/blueprints/components/global-header.yaml +120 -0
- package/skills/applying-slds/metadata/blueprints/components/global-navigation.yaml +100 -0
- package/skills/applying-slds/metadata/blueprints/components/icons.yaml +138 -0
- package/skills/applying-slds/metadata/blueprints/components/illustration.yaml +205 -0
- package/skills/applying-slds/metadata/blueprints/components/input.yaml +151 -0
- package/skills/applying-slds/metadata/blueprints/components/list-builder.yaml +127 -0
- package/skills/applying-slds/metadata/blueprints/components/lookups.yaml +132 -0
- package/skills/applying-slds/metadata/blueprints/components/map.yaml +118 -0
- package/skills/applying-slds/metadata/blueprints/components/menus.yaml +134 -0
- package/skills/applying-slds/metadata/blueprints/components/modals.yaml +152 -0
- package/skills/applying-slds/metadata/blueprints/components/notifications.yaml +88 -0
- package/skills/applying-slds/metadata/blueprints/components/page-headers.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/panels.yaml +149 -0
- package/skills/applying-slds/metadata/blueprints/components/path.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/picklist.yaml +125 -0
- package/skills/applying-slds/metadata/blueprints/components/pills.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/popovers.yaml +120 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-bar.yaml +110 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-indicator.yaml +133 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-ring.yaml +102 -0
- package/skills/applying-slds/metadata/blueprints/components/prompt.yaml +126 -0
- package/skills/applying-slds/metadata/blueprints/components/publishers.yaml +178 -0
- package/skills/applying-slds/metadata/blueprints/components/radio-button-group.yaml +172 -0
- package/skills/applying-slds/metadata/blueprints/components/radio-group.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/rich-text-editor.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/scoped-notifications.yaml +188 -0
- package/skills/applying-slds/metadata/blueprints/components/scoped-tabs.yaml +97 -0
- package/skills/applying-slds/metadata/blueprints/components/select.yaml +127 -0
- package/skills/applying-slds/metadata/blueprints/components/setup-assistant.yaml +152 -0
- package/skills/applying-slds/metadata/blueprints/components/slider.yaml +111 -0
- package/skills/applying-slds/metadata/blueprints/components/spinners.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/split-view.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/summary-detail.yaml +103 -0
- package/skills/applying-slds/metadata/blueprints/components/tabs.yaml +138 -0
- package/skills/applying-slds/metadata/blueprints/components/textarea.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/tiles.yaml +108 -0
- package/skills/applying-slds/metadata/blueprints/components/timepicker.yaml +111 -0
- package/skills/applying-slds/metadata/blueprints/components/toast.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/tooltips.yaml +107 -0
- package/skills/applying-slds/metadata/blueprints/components/tree-grid.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/trees.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/trial-bar.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/vertical-navigation.yaml +130 -0
- package/skills/applying-slds/metadata/blueprints/components/vertical-tabs.yaml +140 -0
- package/skills/applying-slds/metadata/blueprints/components/visual-picker.yaml +150 -0
- package/skills/applying-slds/metadata/blueprints/components/welcome-mat.yaml +136 -0
- package/skills/applying-slds/metadata/hooks-index.json +6272 -0
- package/skills/applying-slds/metadata/icon-metadata.json +38466 -0
- package/skills/applying-slds/metadata/utilities-index.json +21912 -0
- package/skills/applying-slds/references/component-selection.md +112 -0
- package/skills/applying-slds/references/icons-decision-guide.md +124 -0
- package/skills/applying-slds/references/styling-decision-guide.md +228 -0
- package/skills/applying-slds/references/utilities-quick-ref.md +125 -0
- package/skills/applying-slds/scripts/search-blueprints.cjs +117 -0
- package/skills/applying-slds/scripts/search-hooks.cjs +139 -0
- package/skills/applying-slds/scripts/search-icons.cjs +174 -0
- package/skills/applying-slds/scripts/search-utilities.cjs +161 -0
- package/skills/building-mobile-apps/SKILL.md +0 -1
- package/skills/building-omnistudio-callable-apex/SKILL.md +0 -1
- package/skills/building-omnistudio-datamapper/SKILL.md +0 -1
- package/skills/building-omnistudio-flexcard/SKILL.md +0 -1
- package/skills/building-omnistudio-integration-procedure/SKILL.md +0 -1
- package/skills/building-omnistudio-omniscript/SKILL.md +0 -1
- package/skills/building-sf-integrations/SKILL.md +0 -1
- package/skills/configuring-connected-apps/SKILL.md +0 -1
- package/skills/connecting-datacloud/SKILL.md +0 -1
- package/skills/creating-b2b-commerce-store/SKILL.md +0 -1
- package/skills/debugging-apex-logs/SKILL.md +0 -1
- package/skills/deploying-metadata/SKILL.md +0 -1
- package/skills/deploying-omnistudio-datapacks/SKILL.md +0 -1
- package/skills/developing-agentforce/SKILL.md +0 -1
- package/skills/fetching-salesforce-docs/SKILL.md +0 -1
- package/skills/generating-custom-lightning-type/SKILL.md +17 -39
- package/skills/generating-custom-lightning-type/assets/primitive-types-and-constraints.md +41 -0
- package/skills/generating-custom-lightning-type/references/widget-rendition.md +124 -0
- package/skills/generating-lwc-components/SKILL.md +0 -1
- package/skills/generating-mermaid-diagrams/SKILL.md +0 -1
- package/skills/generating-visual-diagrams/SKILL.md +0 -1
- package/skills/handling-sf-data/SKILL.md +0 -1
- package/skills/harmonizing-datacloud/SKILL.md +0 -1
- package/skills/integrating-b2b-commerce-open-code-components/SKILL.md +0 -1
- package/skills/investigating-agentforce-architecture/README.md +156 -0
- package/skills/investigating-agentforce-architecture/SKILL.md +230 -0
- package/skills/investigating-agentforce-architecture/assets/cli/describe_sobject.yaml +16 -0
- package/skills/investigating-agentforce-architecture/assets/cli/describe_tooling_sobject.yaml +17 -0
- package/skills/investigating-agentforce-architecture/assets/cli/list_metadata_genaiprompttemplate.yaml +17 -0
- package/skills/investigating-agentforce-architecture/assets/cli/org_display.yaml +15 -0
- package/skills/investigating-agentforce-architecture/assets/cli/retrieve_genai_plugin.yaml +18 -0
- package/skills/investigating-agentforce-architecture/assets/cli/show_access_token.yaml +27 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/action_tree.mmd +20 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/data_flow.mmd +19 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/dependency_graph.mmd +19 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/invocation_sequence.mmd +20 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/planner_state.mmd +18 -0
- package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_names.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/bot_definition_details.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/bot_version_lookup.soql +4 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_by_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_ids_by_names.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_view_by_durable_ids.soql +4 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_metadata_by_id.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/functions_by_plugins.soql +5 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_attrs_by_parent_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_bundle_functions.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_definition_by_agent_chain.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugin_functions_by_plugin_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugin_instructions_by_plugin_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugins_by_planner.soql +4 -0
- package/skills/investigating-agentforce-architecture/references/architecture_sections.md +243 -0
- package/skills/investigating-agentforce-architecture/references/contract.json +244 -0
- package/skills/investigating-agentforce-architecture/references/soql_fields.md +512 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/__init__.py +1 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/fs_guard.py +329 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/paths.py +110 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/runtime.py +59 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/sql.py +10 -0
- package/skills/investigating-agentforce-architecture/scripts/cache_check.py +234 -0
- package/skills/investigating-agentforce-architecture/scripts/config.py +131 -0
- package/skills/investigating-agentforce-architecture/scripts/fetch_soql.py +689 -0
- package/skills/investigating-agentforce-architecture/scripts/finalize.py +295 -0
- package/skills/investigating-agentforce-architecture/scripts/main.py +2835 -0
- package/skills/investigating-agentforce-architecture/scripts/metadata_listing.py +265 -0
- package/skills/investigating-agentforce-architecture/scripts/parallel_retrieve.py +69 -0
- package/skills/investigating-agentforce-architecture/scripts/parse_bundle.py +215 -0
- package/skills/investigating-agentforce-architecture/scripts/parse_wave.py +845 -0
- package/skills/investigating-agentforce-architecture/scripts/probe_channels.py +302 -0
- package/skills/investigating-agentforce-architecture/scripts/render_architecture.py +1043 -0
- package/skills/investigating-agentforce-architecture/scripts/resolve_bot.py +255 -0
- package/skills/investigating-agentforce-architecture/scripts/resolve_invocation_target.py +130 -0
- package/skills/investigating-agentforce-architecture/scripts/rest_client.py +763 -0
- package/skills/investigating-agentforce-architecture/scripts/retrieve_planner.py +13 -0
- package/skills/investigating-agentforce-architecture/scripts/sf_cli.py +242 -0
- package/skills/investigating-agentforce-architecture/scripts/soql_loader.py +253 -0
- package/skills/investigating-agentforce-architecture/scripts/summarize_tree.py +143 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/__init__.py +0 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/_bootstrap.py +23 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/__init__.py +0 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/genai_payloads.py +400 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check.py +307 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check_main.py +283 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_config.py +115 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_end_to_end_fixture.py +651 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_finalize.py +278 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_flow_children_inflation.py +582 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_fs_guard.py +113 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_iterative_wave_b.py +478 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_main_pipeline.py +3359 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parallel_retrieve.py +131 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_bundle.py +400 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave.py +644 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_classifiers.py +224 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_helpers.py +380 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_main.py +397 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_per_branch_visited.py +244 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_channels.py +359 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_cli_recipes.py +185 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_render_architecture.py +810 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_bot.py +203 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_creds.py +157 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_invocation_target.py +145 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_rest_client.py +1253 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_runtime_override.py +100 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_sf_cli.py +261 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_signature_stamping.py +466 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_soql_loader.py +501 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_summarize_tree.py +241 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_write_emit_ctx.py +480 -0
- package/skills/investigating-agentforce-architecture/tools/emit_env.py +157 -0
- package/skills/investigating-agentforce-architecture/tools/emit_result.py +262 -0
- package/skills/investigating-agentforce-architecture/tools/sanitize.py +33 -0
- package/skills/investigating-agentforce-architecture/tools/write_emit_ctx.py +332 -0
- package/skills/investigating-agentforce-d360/README.md +123 -0
- package/skills/investigating-agentforce-d360/SKILL.md +163 -0
- package/skills/investigating-agentforce-d360/assets/dc/app_generation.sql +51 -0
- package/skills/investigating-agentforce-d360/assets/dc/content_category.sql +44 -0
- package/skills/investigating-agentforce-d360/assets/dc/content_quality.sql +41 -0
- package/skills/investigating-agentforce-d360/assets/dc/discover_sessions.sql +36 -0
- package/skills/investigating-agentforce-d360/assets/dc/feedback.sql +47 -0
- package/skills/investigating-agentforce-d360/assets/dc/feedback_details.sql +38 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_records.sql +45 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_llm.sql +50 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_metadata.sql +44 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_tags.sql +42 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_requests.sql +89 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_responses.sql +43 -0
- package/skills/investigating-agentforce-d360/assets/dc/generations.sql +52 -0
- package/skills/investigating-agentforce-d360/assets/dc/interactions.sql +53 -0
- package/skills/investigating-agentforce-d360/assets/dc/messages.sql +53 -0
- package/skills/investigating-agentforce-d360/assets/dc/messaging_session.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/moment_interactions.sql +34 -0
- package/skills/investigating-agentforce-d360/assets/dc/moments.sql +39 -0
- package/skills/investigating-agentforce-d360/assets/dc/participants.sql +48 -0
- package/skills/investigating-agentforce-d360/assets/dc/sessions.sql +78 -0
- package/skills/investigating-agentforce-d360/assets/dc/steps.sql +64 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_associations.sql +46 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_definition_associations.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_definitions.sql +50 -0
- package/skills/investigating-agentforce-d360/assets/dc/tags.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/telemetry_spans.sql +55 -0
- package/skills/investigating-agentforce-d360/references/artifacts.md +50 -0
- package/skills/investigating-agentforce-d360/references/dc_dmo_fields.md +823 -0
- package/skills/investigating-agentforce-d360/references/dc_pipeline_contract.md +608 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/__init__.py +2 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/cli_override.py +98 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/fs_guard.py +334 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/paths.py +155 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/runtime.py +59 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/sql.py +14 -0
- package/skills/investigating-agentforce-d360/scripts/assemble_dc.py +1624 -0
- package/skills/investigating-agentforce-d360/scripts/config.py +45 -0
- package/skills/investigating-agentforce-d360/scripts/dc.py +188 -0
- package/skills/investigating-agentforce-d360/scripts/discover_sessions.py +556 -0
- package/skills/investigating-agentforce-d360/scripts/fetch_dc.py +1045 -0
- package/skills/investigating-agentforce-d360/scripts/render_dc.py +1750 -0
- package/skills/investigating-agentforce-d360/scripts/resolve_session.py +264 -0
- package/skills/investigating-agentforce-d360/scripts/storage.py +92 -0
- package/skills/investigating-agentforce-d360/scripts/tests/__init__.py +0 -0
- package/skills/investigating-agentforce-d360/scripts/tests/_bootstrap.py +15 -0
- package/skills/investigating-agentforce-d360/scripts/tests/fixtures/__init__.py +0 -0
- package/skills/investigating-agentforce-d360/scripts/tests/fixtures/synthetic_session.py +424 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_bootstrap_and_mode.py +115 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct.py +220 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct_integration.py +158 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_helpers.py +287 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_integration.py +247 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_dc_and_resolve_session.py +433 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions.py +458 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions_grep_ci.py +193 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_helpers.py +266 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_identity.py +528 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_main.py +251 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall.py +229 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall_full.py +283 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_identity_coherence.py +327 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_branches.py +256 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_gateway_direct.py +130 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_helpers.py +291 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_integration.py +220 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_planner_llm_calls.py +284 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_show_prompts_gating.py +215 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_from_disk.py +100 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_session_main.py +149 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_runtime_override.py +104 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape.py +95 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape_dropped_by_stdm.py +85 -0
- package/skills/managing-managed-event-subscription/SKILL.md +152 -0
- package/skills/managing-managed-event-subscription/assets/managed-event-subscription-template.xml +20 -0
- package/skills/managing-managed-event-subscription/references/delete-guide.md +57 -0
- package/skills/managing-managed-event-subscription/references/topic-name-formats.md +26 -0
- package/skills/managing-managed-event-subscription/references/update-constraints.md +30 -0
- package/skills/modeling-omnistudio-epc-catalog/SKILL.md +0 -1
- package/skills/observing-agentforce/SKILL.md +0 -1
- package/skills/orchestrating-datacloud/SKILL.md +0 -1
- package/skills/preparing-datacloud/SKILL.md +0 -1
- package/skills/querying-soql/SKILL.md +0 -1
- package/skills/retrieving-datacloud/SKILL.md +0 -1
- package/skills/running-apex-tests/SKILL.md +0 -1
- package/skills/running-code-analyzer/SKILL.md +0 -1
- package/skills/segmenting-datacloud/SKILL.md +0 -1
- package/skills/testing-agentforce/SKILL.md +0 -1
- package/skills/uplifting-components-to-slds2/SKILL.md +3 -2
- package/skills/uplifting-components-to-slds2/references/color-hooks-decision-guide.md +30 -9
- package/skills/uplifting-components-to-slds2/references/examples.md +24 -6
- package/skills/validating-slds/SKILL.md +262 -0
- package/skills/validating-slds/references/quality-checks.md +308 -0
- package/skills/validating-slds/references/report-format.md +302 -0
- package/skills/validating-slds/scripts/analyze-quality.cjs +521 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
"""Tests for (cross-host redirect strips Authorization) and
|
|
2
|
+
(redact_error scrubs bearer tokens).
|
|
3
|
+
|
|
4
|
+
adds: `retry_on_401` decorator + `tooling_query` / `data_query`
|
|
5
|
+
helpers. Tests cover both the HTTP-401 and the INVALID_SESSION_ID-in-body
|
|
6
|
+
code paths, as well as the non-auth-error propagation guarantee.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import email.message
|
|
11
|
+
import email.utils
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import unittest
|
|
16
|
+
import unittest.mock as mock
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from . import _bootstrap # noqa: F401
|
|
21
|
+
|
|
22
|
+
import rest_client # type: ignore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RedirectHeaderStripTests(unittest.TestCase):
|
|
26
|
+
"""Authorization must be dropped on cross-host redirect."""
|
|
27
|
+
|
|
28
|
+
def setUp(self) -> None:
|
|
29
|
+
self.handler = rest_client.StripAuthOnCrossHostRedirect()
|
|
30
|
+
# Minimal stand-in for the `fp` argument — the handler ignores it
|
|
31
|
+
# except when constructing HTTPError on unsupported-scheme paths.
|
|
32
|
+
self.fp = io.BytesIO(b"")
|
|
33
|
+
# Headers are a Message-like; handler reads nothing from it for
|
|
34
|
+
# the 302 path. An empty dict-like suffices.
|
|
35
|
+
self.msg = {}
|
|
36
|
+
|
|
37
|
+
def _make_request(self, url: str, auth: str = "Bearer tok123"):
|
|
38
|
+
req = urllib.request.Request(url)
|
|
39
|
+
if auth:
|
|
40
|
+
req.add_header("Authorization", auth)
|
|
41
|
+
return req
|
|
42
|
+
|
|
43
|
+
def _has_authorization(self, req) -> bool:
|
|
44
|
+
for k in list(req.headers.keys()) + list(req.unredirected_hdrs.keys()):
|
|
45
|
+
if k.lower() == "authorization":
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def test_same_host_redirect_preserves_authorization(self):
|
|
50
|
+
req = self._make_request("https://na1.salesforce.com/services/data/v60.0/query")
|
|
51
|
+
new_req = self.handler.redirect_request(
|
|
52
|
+
req, self.fp, 302, "Found", self.msg,
|
|
53
|
+
"https://na1.salesforce.com/services/data/v60.0/query?page=2",
|
|
54
|
+
)
|
|
55
|
+
self.assertIsNotNone(new_req)
|
|
56
|
+
self.assertTrue(
|
|
57
|
+
self._has_authorization(new_req),
|
|
58
|
+
"same-host redirect must preserve Authorization",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_cross_host_redirect_strips_authorization(self):
|
|
62
|
+
req = self._make_request("https://na1.salesforce.com/services/data/v60.0/query")
|
|
63
|
+
new_req = self.handler.redirect_request(
|
|
64
|
+
req, self.fp, 302, "Found", self.msg,
|
|
65
|
+
"https://attacker.example.com/steal",
|
|
66
|
+
)
|
|
67
|
+
self.assertIsNotNone(new_req)
|
|
68
|
+
self.assertFalse(
|
|
69
|
+
self._has_authorization(new_req),
|
|
70
|
+
"cross-host redirect must strip Authorization ",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def test_downgrade_to_http_cross_host_strips(self):
|
|
74
|
+
"""A redirect from HTTPS→HTTP on a different host is the classic
|
|
75
|
+
MITM bait. Authorization must not follow."""
|
|
76
|
+
req = self._make_request("https://na1.salesforce.com/services/data/v60.0/query")
|
|
77
|
+
new_req = self.handler.redirect_request(
|
|
78
|
+
req, self.fp, 302, "Found", self.msg,
|
|
79
|
+
"http://attacker.example.com/",
|
|
80
|
+
)
|
|
81
|
+
self.assertIsNotNone(new_req)
|
|
82
|
+
self.assertFalse(self._has_authorization(new_req))
|
|
83
|
+
|
|
84
|
+
def test_case_insensitive_host_match(self):
|
|
85
|
+
"""DNS is case-insensitive — `Foo.com` and `foo.com` are same host."""
|
|
86
|
+
req = self._make_request("https://Foo.Salesforce.com/path")
|
|
87
|
+
new_req = self.handler.redirect_request(
|
|
88
|
+
req, self.fp, 302, "Found", self.msg,
|
|
89
|
+
"https://foo.salesforce.com/other",
|
|
90
|
+
)
|
|
91
|
+
self.assertIsNotNone(new_req)
|
|
92
|
+
self.assertTrue(self._has_authorization(new_req))
|
|
93
|
+
|
|
94
|
+
def test_build_opener_wires_the_handler(self):
|
|
95
|
+
opener = rest_client.build_opener()
|
|
96
|
+
self.assertIsInstance(opener, urllib.request.OpenerDirector)
|
|
97
|
+
installed = [
|
|
98
|
+
h for h in opener.handlers
|
|
99
|
+
if isinstance(h, rest_client.StripAuthOnCrossHostRedirect)
|
|
100
|
+
]
|
|
101
|
+
self.assertEqual(
|
|
102
|
+
len(installed), 1,
|
|
103
|
+
"build_opener must install exactly one StripAuthOnCrossHostRedirect",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RedactErrorTests(unittest.TestCase):
|
|
108
|
+
"""redact_error must scrub bearer tokens from exception strings."""
|
|
109
|
+
|
|
110
|
+
def test_bearer_token_redacted(self):
|
|
111
|
+
# Scanner-inert placeholder — redactor only cares about the
|
|
112
|
+
# `Authorization: Bearer ` prefix; the value is opaque `\S+`.
|
|
113
|
+
raw = "Authorization: Bearer TESTONLY_HEADER.TESTONLY_PAYLOAD.TESTONLY_SIG"
|
|
114
|
+
exc = RuntimeError(raw)
|
|
115
|
+
out = rest_client.redact_error(exc)
|
|
116
|
+
self.assertIn("<redacted>", out)
|
|
117
|
+
self.assertNotIn("TESTONLY_HEADER", out)
|
|
118
|
+
self.assertNotIn("TESTONLY_PAYLOAD", out)
|
|
119
|
+
|
|
120
|
+
def test_bearer_token_case_insensitive(self):
|
|
121
|
+
exc = RuntimeError("authorization: BEARER TESTONLY_BEARER_VALUE")
|
|
122
|
+
out = rest_client.redact_error(exc)
|
|
123
|
+
self.assertNotIn("TESTONLY_BEARER_VALUE", out)
|
|
124
|
+
|
|
125
|
+
def test_access_token_querystring_redacted(self):
|
|
126
|
+
exc = RuntimeError("POST /oauth/token accessToken=TESTONLY_TOKEN&foo=1")
|
|
127
|
+
out = rest_client.redact_error(exc)
|
|
128
|
+
self.assertNotIn("TESTONLY_TOKEN", out)
|
|
129
|
+
self.assertIn("<redacted>", out)
|
|
130
|
+
# The non-sensitive tail is preserved — handy for debugging.
|
|
131
|
+
self.assertIn("foo=1", out)
|
|
132
|
+
|
|
133
|
+
def test_access_token_snake_case_querystring_redacted(self):
|
|
134
|
+
exc = RuntimeError("access_token=TESTONLY_SNAKE_TOKEN&other=ok")
|
|
135
|
+
out = rest_client.redact_error(exc)
|
|
136
|
+
self.assertNotIn("TESTONLY_SNAKE_TOKEN", out)
|
|
137
|
+
self.assertIn("other=ok", out)
|
|
138
|
+
|
|
139
|
+
def test_access_token_json_redacted(self):
|
|
140
|
+
exc = RuntimeError('{"accessToken":"TESTONLY_JSON_TOKEN","status":0}')
|
|
141
|
+
out = rest_client.redact_error(exc)
|
|
142
|
+
self.assertNotIn("TESTONLY_JSON_TOKEN", out)
|
|
143
|
+
self.assertIn("<redacted>", out)
|
|
144
|
+
# Non-sensitive JSON preserved.
|
|
145
|
+
self.assertIn("status", out)
|
|
146
|
+
|
|
147
|
+
def test_access_token_json_snake_case_redacted(self):
|
|
148
|
+
exc = RuntimeError('{"access_token":"tok.ABC"}')
|
|
149
|
+
out = rest_client.redact_error(exc)
|
|
150
|
+
self.assertNotIn("tok.ABC", out)
|
|
151
|
+
|
|
152
|
+
def test_output_includes_exception_type(self):
|
|
153
|
+
exc = ValueError("boring message")
|
|
154
|
+
out = rest_client.redact_error(exc)
|
|
155
|
+
self.assertTrue(out.startswith("ValueError:"))
|
|
156
|
+
|
|
157
|
+
def test_empty_exception_safe(self):
|
|
158
|
+
exc = RuntimeError("")
|
|
159
|
+
out = rest_client.redact_error(exc)
|
|
160
|
+
self.assertTrue(out.startswith("RuntimeError:"))
|
|
161
|
+
|
|
162
|
+
def test_plain_message_unchanged(self):
|
|
163
|
+
exc = RuntimeError("connection refused")
|
|
164
|
+
out = rest_client.redact_error(exc)
|
|
165
|
+
self.assertIn("connection refused", out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RedactTextPublicSymbolTests(unittest.TestCase):
|
|
169
|
+
"""`redact_text` is the public symbol; `_redact_text` stays as
|
|
170
|
+
a deprecated alias for backwards compatibility.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def test_redact_text_importable_at_module_top(self):
|
|
174
|
+
"""Public name must be reachable via a plain `from` import — this
|
|
175
|
+
is what sf_cli now relies on at module-top import time.
|
|
176
|
+
"""
|
|
177
|
+
from rest_client import redact_text # noqa: F401
|
|
178
|
+
self.assertTrue(callable(redact_text))
|
|
179
|
+
|
|
180
|
+
def test_redact_text_same_behavior_as_alias(self):
|
|
181
|
+
"""The deprecated alias must be bound to the same function object
|
|
182
|
+
so behavior drift is impossible.
|
|
183
|
+
"""
|
|
184
|
+
self.assertIs(rest_client._redact_text, rest_client.redact_text)
|
|
185
|
+
|
|
186
|
+
def test_underscore_alias_still_works(self):
|
|
187
|
+
"""Backwards-compat: any lingering `from rest_client import
|
|
188
|
+
_redact_text` must keep functioning until the alias is removed
|
|
189
|
+
in a follow-up batch.
|
|
190
|
+
"""
|
|
191
|
+
from rest_client import _redact_text # noqa: F401
|
|
192
|
+
out = _redact_text("Authorization: Bearer tokXYZ")
|
|
193
|
+
self.assertIn("<redacted>", out)
|
|
194
|
+
self.assertNotIn("tokXYZ", out)
|
|
195
|
+
|
|
196
|
+
def test_public_redact_text_scrubs_bearer(self):
|
|
197
|
+
out = rest_client.redact_text("Authorization: Bearer secrettok")
|
|
198
|
+
self.assertNotIn("secrettok", out)
|
|
199
|
+
self.assertIn("<redacted>", out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
|
|
203
|
+
"""Build a minimal HTTPError carrying `code` + `body`.
|
|
204
|
+
|
|
205
|
+
We don't need a real socket — the decorator only reads `.code` and
|
|
206
|
+
(optionally) the stringified form. Body is passed for parity with
|
|
207
|
+
the wire.
|
|
208
|
+
"""
|
|
209
|
+
return urllib.error.HTTPError(
|
|
210
|
+
url="https://example.salesforce.com/x",
|
|
211
|
+
code=code,
|
|
212
|
+
msg="error",
|
|
213
|
+
hdrs={}, # type: ignore[arg-type]
|
|
214
|
+
fp=io.BytesIO(body),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RetryOn401Tests(unittest.TestCase):
|
|
219
|
+
"""decorator refreshes the token on 401 and retries once."""
|
|
220
|
+
|
|
221
|
+
def test_401_then_success_calls_refresh_once(self):
|
|
222
|
+
"""Wrapped fn raises 401 first, succeeds on retry.
|
|
223
|
+
|
|
224
|
+
refresh_fn MUST be called exactly once; wrapped fn returns normally.
|
|
225
|
+
"""
|
|
226
|
+
call_count = {"n": 0}
|
|
227
|
+
|
|
228
|
+
def wrapped():
|
|
229
|
+
call_count["n"] += 1
|
|
230
|
+
if call_count["n"] == 1:
|
|
231
|
+
raise _make_http_error(401)
|
|
232
|
+
return {"ok": True}
|
|
233
|
+
|
|
234
|
+
refresh_calls = []
|
|
235
|
+
|
|
236
|
+
def refresh():
|
|
237
|
+
refresh_calls.append(1)
|
|
238
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
239
|
+
|
|
240
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
241
|
+
result = decorated()
|
|
242
|
+
|
|
243
|
+
self.assertEqual(result, {"ok": True})
|
|
244
|
+
self.assertEqual(len(refresh_calls), 1, "refresh_fn must be called exactly once")
|
|
245
|
+
self.assertEqual(call_count["n"], 2)
|
|
246
|
+
|
|
247
|
+
def test_401_twice_reraises_with_original_context(self):
|
|
248
|
+
"""Second 401 → original 401 context surfaces (wrapped as RestClientError).
|
|
249
|
+
|
|
250
|
+
refresh_fn still called only once (no infinite retry).
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def wrapped():
|
|
254
|
+
raise _make_http_error(401, body=b"session bad")
|
|
255
|
+
|
|
256
|
+
refresh_calls = []
|
|
257
|
+
|
|
258
|
+
def refresh():
|
|
259
|
+
refresh_calls.append(1)
|
|
260
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
261
|
+
|
|
262
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
263
|
+
with self.assertRaises(rest_client.RestClientError) as ctx:
|
|
264
|
+
decorated()
|
|
265
|
+
|
|
266
|
+
self.assertIn("auth error after refresh", str(ctx.exception))
|
|
267
|
+
self.assertEqual(len(refresh_calls), 1, "refresh_fn must be called only once")
|
|
268
|
+
|
|
269
|
+
def test_invalid_session_body_triggers_refresh(self):
|
|
270
|
+
"""INVALID_SESSION_ID signalled via body (HTTP 200) → refresh + retry."""
|
|
271
|
+
call_count = {"n": 0}
|
|
272
|
+
|
|
273
|
+
def wrapped():
|
|
274
|
+
call_count["n"] += 1
|
|
275
|
+
if call_count["n"] == 1:
|
|
276
|
+
raise rest_client._InvalidSessionSignal("body had INVALID_SESSION_ID")
|
|
277
|
+
return {"ok": True}
|
|
278
|
+
|
|
279
|
+
refresh_calls = []
|
|
280
|
+
|
|
281
|
+
def refresh():
|
|
282
|
+
refresh_calls.append(1)
|
|
283
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
284
|
+
|
|
285
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
286
|
+
result = decorated()
|
|
287
|
+
|
|
288
|
+
self.assertEqual(result, {"ok": True})
|
|
289
|
+
self.assertEqual(len(refresh_calls), 1)
|
|
290
|
+
|
|
291
|
+
def test_non_auth_http_error_propagates_without_refresh(self):
|
|
292
|
+
"""500 → NO refresh, error propagates as-is."""
|
|
293
|
+
|
|
294
|
+
def wrapped():
|
|
295
|
+
raise _make_http_error(500)
|
|
296
|
+
|
|
297
|
+
refresh_calls = []
|
|
298
|
+
|
|
299
|
+
def refresh():
|
|
300
|
+
refresh_calls.append(1)
|
|
301
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
302
|
+
|
|
303
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
304
|
+
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
305
|
+
decorated()
|
|
306
|
+
|
|
307
|
+
self.assertEqual(ctx.exception.code, 500)
|
|
308
|
+
self.assertEqual(len(refresh_calls), 0, "500 must NOT trigger refresh")
|
|
309
|
+
|
|
310
|
+
def test_retry_error_messages_go_through_redact_error(self):
|
|
311
|
+
"""A token baked into the original 401 body must not survive in the
|
|
312
|
+
RestClientError that gets re-raised on the second 401.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def wrapped():
|
|
316
|
+
# The HTTPError's str form echoes url + code. We bake a bearer
|
|
317
|
+
# token into the msg to exercise the redact path on the
|
|
318
|
+
# re-raise.
|
|
319
|
+
err = urllib.error.HTTPError(
|
|
320
|
+
url="https://example.salesforce.com",
|
|
321
|
+
code=401,
|
|
322
|
+
msg="Authorization: Bearer TESTONLY_HTTPERROR_TOKEN",
|
|
323
|
+
hdrs={}, # type: ignore[arg-type]
|
|
324
|
+
fp=io.BytesIO(b""),
|
|
325
|
+
)
|
|
326
|
+
raise err
|
|
327
|
+
|
|
328
|
+
def refresh():
|
|
329
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
330
|
+
|
|
331
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
332
|
+
with self.assertRaises(rest_client.RestClientError) as ctx:
|
|
333
|
+
decorated()
|
|
334
|
+
msg = str(ctx.exception)
|
|
335
|
+
self.assertNotIn("TESTONLY_HTTPERROR_TOKEN", msg)
|
|
336
|
+
# The redaction sentinel must appear somewhere — proof the
|
|
337
|
+
# substitution actually ran.
|
|
338
|
+
self.assertIn("<redacted>", msg)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class QueryHelperTests(unittest.TestCase):
|
|
342
|
+
"""tooling_query + data_query — happy paths with mocked opener."""
|
|
343
|
+
|
|
344
|
+
def _install_mock_opener(self, response_body: bytes):
|
|
345
|
+
"""Monkeypatch build_opener to return an opener whose .open returns
|
|
346
|
+
a context manager yielding an object with .read() == response_body.
|
|
347
|
+
"""
|
|
348
|
+
fake_resp = mock.MagicMock()
|
|
349
|
+
fake_resp.read.return_value = response_body
|
|
350
|
+
fake_resp.__enter__ = mock.MagicMock(return_value=fake_resp)
|
|
351
|
+
fake_resp.__exit__ = mock.MagicMock(return_value=False)
|
|
352
|
+
|
|
353
|
+
fake_opener = mock.MagicMock()
|
|
354
|
+
fake_opener.open = mock.MagicMock(return_value=fake_resp)
|
|
355
|
+
return mock.patch.object(
|
|
356
|
+
rest_client, "build_opener", return_value=fake_opener
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def test_tooling_query_happy_path(self):
|
|
360
|
+
body = json.dumps({"records": [{"Id": "01p000000000001"}]}).encode("utf-8")
|
|
361
|
+
with self._install_mock_opener(body):
|
|
362
|
+
out = rest_client.tooling_query(
|
|
363
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
364
|
+
"SELECT Id FROM ApexClass",
|
|
365
|
+
api_version="v60.0",
|
|
366
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
367
|
+
)
|
|
368
|
+
self.assertEqual(out["records"][0]["Id"], "01p000000000001")
|
|
369
|
+
|
|
370
|
+
def test_data_query_happy_path(self):
|
|
371
|
+
body = json.dumps({"totalSize": 0, "records": []}).encode("utf-8")
|
|
372
|
+
with self._install_mock_opener(body):
|
|
373
|
+
out = rest_client.data_query(
|
|
374
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
375
|
+
"SELECT Id FROM Account LIMIT 1",
|
|
376
|
+
api_version="v60.0",
|
|
377
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
378
|
+
)
|
|
379
|
+
self.assertEqual(out["totalSize"], 0)
|
|
380
|
+
|
|
381
|
+
def test_tooling_query_apex_body_with_marker_does_not_trigger_refresh(self):
|
|
382
|
+
"""BUGFIX 2026-05-03: 200 OK Tooling response where ApexClass.Body
|
|
383
|
+
references `INVALID_SESSION_ID` in source must pass through
|
|
384
|
+
unchanged — no refresh, no retry, no error wrapping."""
|
|
385
|
+
good_body = json.dumps({
|
|
386
|
+
"size": 1,
|
|
387
|
+
"totalSize": 1,
|
|
388
|
+
"done": True,
|
|
389
|
+
"queryLocator": None,
|
|
390
|
+
"entityTypeName": "ApexClass",
|
|
391
|
+
"records": [
|
|
392
|
+
{
|
|
393
|
+
"attributes": {"type": "ApexClass"},
|
|
394
|
+
"Id": "01pUv000003a1YaIAI",
|
|
395
|
+
"Name": "XCSF_FlowFaultMessage",
|
|
396
|
+
"Body": "// if (code == 'INVALID_SESSION_ID') rethrow;",
|
|
397
|
+
}
|
|
398
|
+
],
|
|
399
|
+
}).encode("utf-8")
|
|
400
|
+
|
|
401
|
+
open_count = {"n": 0}
|
|
402
|
+
|
|
403
|
+
def fake_open(req):
|
|
404
|
+
open_count["n"] += 1
|
|
405
|
+
resp = mock.MagicMock()
|
|
406
|
+
resp.read.return_value = good_body
|
|
407
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
408
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
409
|
+
return resp
|
|
410
|
+
|
|
411
|
+
fake_opener = mock.MagicMock()
|
|
412
|
+
fake_opener.open = fake_open
|
|
413
|
+
|
|
414
|
+
refresh_calls = []
|
|
415
|
+
|
|
416
|
+
def refresh():
|
|
417
|
+
refresh_calls.append(1)
|
|
418
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
419
|
+
|
|
420
|
+
with mock.patch.object(rest_client, "build_opener", return_value=fake_opener):
|
|
421
|
+
out = rest_client.tooling_query(
|
|
422
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
423
|
+
"SELECT Id, Name, Body FROM ApexClass WHERE Id IN (...)",
|
|
424
|
+
api_version="v66.0",
|
|
425
|
+
on_401_refresh=refresh,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
self.assertEqual(out["totalSize"], 1)
|
|
429
|
+
self.assertEqual(out["records"][0]["Name"], "XCSF_FlowFaultMessage")
|
|
430
|
+
self.assertEqual(open_count["n"], 1,
|
|
431
|
+
"exactly one HTTP call — no spurious retry")
|
|
432
|
+
self.assertEqual(refresh_calls, [],
|
|
433
|
+
"refresh must not fire on a 200 OK success payload")
|
|
434
|
+
|
|
435
|
+
def test_tooling_query_invalid_session_in_body_triggers_refresh(self):
|
|
436
|
+
"""HTTP 200 with INVALID_SESSION_ID body → refresh closure invoked."""
|
|
437
|
+
bad_body = json.dumps([
|
|
438
|
+
{"errorCode": "INVALID_SESSION_ID", "message": "Session expired"}
|
|
439
|
+
]).encode("utf-8")
|
|
440
|
+
good_body = json.dumps({"records": []}).encode("utf-8")
|
|
441
|
+
|
|
442
|
+
# Two-shot opener: first call returns bad_body, second returns good.
|
|
443
|
+
responses = [bad_body, good_body]
|
|
444
|
+
|
|
445
|
+
def fake_open(req):
|
|
446
|
+
resp = mock.MagicMock()
|
|
447
|
+
resp.read.return_value = responses.pop(0)
|
|
448
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
449
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
450
|
+
return resp
|
|
451
|
+
|
|
452
|
+
fake_opener = mock.MagicMock()
|
|
453
|
+
fake_opener.open = fake_open
|
|
454
|
+
|
|
455
|
+
refresh_calls = []
|
|
456
|
+
|
|
457
|
+
def refresh():
|
|
458
|
+
refresh_calls.append(1)
|
|
459
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
460
|
+
|
|
461
|
+
with mock.patch.object(rest_client, "build_opener", return_value=fake_opener):
|
|
462
|
+
out = rest_client.tooling_query(
|
|
463
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
464
|
+
"SELECT Id FROM ApexClass",
|
|
465
|
+
api_version="v60.0",
|
|
466
|
+
on_401_refresh=refresh,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
self.assertEqual(out, {"records": []})
|
|
470
|
+
self.assertEqual(len(refresh_calls), 1)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class RetryOn401CredentialRefreshIntegrationTests(unittest.TestCase):
|
|
474
|
+
"""Verify the retry carries the NEW token, not the stale one.
|
|
475
|
+
|
|
476
|
+
The previous design looked correct under unit tests (refresh_fn called
|
|
477
|
+
once, retry returns success) but was broken end-to-end: the closure
|
|
478
|
+
around `(instance_url, token)` was captured at decoration time, so
|
|
479
|
+
`refresh_fn`'s return value was thrown away and the retry saw the
|
|
480
|
+
SAME stale credentials. Any real 401 (bad token) would hit the retry
|
|
481
|
+
with the same bad token and 401 again.
|
|
482
|
+
|
|
483
|
+
This test exercises a full tooling_query through a mock opener that
|
|
484
|
+
401s for the stale token and succeeds for the refreshed token. A
|
|
485
|
+
fix that only changes decorator bookkeeping (without threading the
|
|
486
|
+
refresh into the retry's actual HTTP call) will fail this test.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
def test_retry_on_401_uses_refreshed_credentials(self):
|
|
490
|
+
# creds cell — a single-slot list mutated by refresh_fn.
|
|
491
|
+
creds_cell: list = [("https://example.salesforce.com", "bad_token")]
|
|
492
|
+
|
|
493
|
+
def creds_provider():
|
|
494
|
+
return creds_cell[0]
|
|
495
|
+
|
|
496
|
+
def refresh_fn():
|
|
497
|
+
creds_cell[0] = ("https://example.salesforce.com", "good_token")
|
|
498
|
+
return creds_cell[0]
|
|
499
|
+
|
|
500
|
+
# Tracks the Authorization header seen on every request — this is
|
|
501
|
+
# what proves whether the retry carried the refreshed token or not.
|
|
502
|
+
auth_headers_seen: list[str] = []
|
|
503
|
+
|
|
504
|
+
def fake_open(req):
|
|
505
|
+
# urllib normalizes header names on add_header; read whichever
|
|
506
|
+
# case lands.
|
|
507
|
+
auth = None
|
|
508
|
+
for k, v in req.headers.items():
|
|
509
|
+
if k.lower() == "authorization":
|
|
510
|
+
auth = v
|
|
511
|
+
break
|
|
512
|
+
auth_headers_seen.append(auth or "")
|
|
513
|
+
|
|
514
|
+
if auth == "Bearer bad_token":
|
|
515
|
+
raise urllib.error.HTTPError(
|
|
516
|
+
url=req.full_url,
|
|
517
|
+
code=401,
|
|
518
|
+
msg="Unauthorized",
|
|
519
|
+
hdrs={}, # type: ignore[arg-type]
|
|
520
|
+
fp=io.BytesIO(b""),
|
|
521
|
+
)
|
|
522
|
+
# Good token path — return a tiny success JSON.
|
|
523
|
+
resp = mock.MagicMock()
|
|
524
|
+
resp.read.return_value = json.dumps({"records": []}).encode("utf-8")
|
|
525
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
526
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
527
|
+
return resp
|
|
528
|
+
|
|
529
|
+
fake_opener = mock.MagicMock()
|
|
530
|
+
fake_opener.open = fake_open
|
|
531
|
+
|
|
532
|
+
with mock.patch.object(rest_client, "build_opener", return_value=fake_opener):
|
|
533
|
+
out = rest_client.tooling_query(
|
|
534
|
+
creds_provider,
|
|
535
|
+
"SELECT Id FROM ApexClass",
|
|
536
|
+
api_version="v60.0",
|
|
537
|
+
on_401_refresh=refresh_fn,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
self.assertEqual(out, {"records": []})
|
|
541
|
+
self.assertEqual(
|
|
542
|
+
len(auth_headers_seen), 2,
|
|
543
|
+
f"expected 2 attempts (401 + retry), got {len(auth_headers_seen)}",
|
|
544
|
+
)
|
|
545
|
+
self.assertEqual(
|
|
546
|
+
auth_headers_seen[0], "Bearer bad_token",
|
|
547
|
+
"first attempt should have used the stale token",
|
|
548
|
+
)
|
|
549
|
+
self.assertEqual(
|
|
550
|
+
auth_headers_seen[1], "Bearer good_token",
|
|
551
|
+
"retry MUST use the refreshed token — this is the bug",
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class TransientHttpWithRefreshIntegrationTests(unittest.TestCase):
|
|
556
|
+
"""End-to-end stack test through `tooling_query`.
|
|
557
|
+
|
|
558
|
+
Drives the real decorator chain baked into `tooling_query`: first
|
|
559
|
+
response 429 (transient retry), second response 401 (refresh + retry),
|
|
560
|
+
third response 200. Proves that the stacking in the production
|
|
561
|
+
helper — NOT just the raw decorators — gives the intended behavior.
|
|
562
|
+
"""
|
|
563
|
+
|
|
564
|
+
def test_tooling_query_429_then_401_then_success(self):
|
|
565
|
+
responses = [
|
|
566
|
+
("429", None),
|
|
567
|
+
("401", None),
|
|
568
|
+
("ok", json.dumps({"records": [{"Id": "a"}]}).encode("utf-8")),
|
|
569
|
+
]
|
|
570
|
+
creds_cell = [("https://example.salesforce.com", "bad_token")]
|
|
571
|
+
refresh_calls = []
|
|
572
|
+
|
|
573
|
+
def creds_provider():
|
|
574
|
+
return creds_cell[0]
|
|
575
|
+
|
|
576
|
+
def refresh():
|
|
577
|
+
refresh_calls.append(1)
|
|
578
|
+
creds_cell[0] = ("https://example.salesforce.com", "good_token")
|
|
579
|
+
return creds_cell[0]
|
|
580
|
+
|
|
581
|
+
def fake_open(req):
|
|
582
|
+
kind, body = responses.pop(0)
|
|
583
|
+
if kind == "429":
|
|
584
|
+
exc = urllib.error.HTTPError(
|
|
585
|
+
url=req.full_url,
|
|
586
|
+
code=429,
|
|
587
|
+
msg="rate limited",
|
|
588
|
+
hdrs=email.message.Message(),
|
|
589
|
+
fp=io.BytesIO(b""),
|
|
590
|
+
)
|
|
591
|
+
exc.headers["Retry-After"] = "0" # 0 → base_delay wins, but sleep is mocked
|
|
592
|
+
raise exc
|
|
593
|
+
if kind == "401":
|
|
594
|
+
raise urllib.error.HTTPError(
|
|
595
|
+
url=req.full_url,
|
|
596
|
+
code=401,
|
|
597
|
+
msg="unauthorized",
|
|
598
|
+
hdrs={}, # type: ignore[arg-type]
|
|
599
|
+
fp=io.BytesIO(b""),
|
|
600
|
+
)
|
|
601
|
+
resp = mock.MagicMock()
|
|
602
|
+
resp.read.return_value = body
|
|
603
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
604
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
605
|
+
return resp
|
|
606
|
+
|
|
607
|
+
fake_opener = mock.MagicMock()
|
|
608
|
+
fake_opener.open = fake_open
|
|
609
|
+
|
|
610
|
+
with mock.patch.object(rest_client, "build_opener", return_value=fake_opener), \
|
|
611
|
+
mock.patch.object(rest_client.time, "sleep"):
|
|
612
|
+
out = rest_client.tooling_query(
|
|
613
|
+
creds_provider,
|
|
614
|
+
"SELECT Id FROM ApexClass",
|
|
615
|
+
api_version="v60.0",
|
|
616
|
+
on_401_refresh=refresh,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
self.assertEqual(out["records"][0]["Id"], "a")
|
|
620
|
+
self.assertEqual(len(refresh_calls), 1,
|
|
621
|
+
"exactly one refresh — 429 must NOT trigger the refresh")
|
|
622
|
+
self.assertEqual(responses, [], "all three canned responses consumed")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class AcceptEncodingAndForbiddenInvalidSessionTests(unittest.TestCase):
|
|
626
|
+
"""Accept-Encoding: identity + 403+INVALID_SESSION_ID handling."""
|
|
627
|
+
|
|
628
|
+
def test_query_sends_accept_encoding_identity(self):
|
|
629
|
+
"""The outbound request carries `Accept-Encoding: identity` so no
|
|
630
|
+
gzip body ever lands on the body-inspection path."""
|
|
631
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
632
|
+
|
|
633
|
+
captured: list = []
|
|
634
|
+
|
|
635
|
+
def fake_open(req):
|
|
636
|
+
captured.append(req)
|
|
637
|
+
resp = mock.MagicMock()
|
|
638
|
+
resp.read.return_value = body
|
|
639
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
640
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
641
|
+
return resp
|
|
642
|
+
|
|
643
|
+
fake_opener = mock.MagicMock()
|
|
644
|
+
fake_opener.open = fake_open
|
|
645
|
+
|
|
646
|
+
with mock.patch.object(rest_client, "build_opener", return_value=fake_opener):
|
|
647
|
+
rest_client.tooling_query(
|
|
648
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
649
|
+
"SELECT Id FROM ApexClass",
|
|
650
|
+
api_version="v60.0",
|
|
651
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
self.assertEqual(len(captured), 1)
|
|
655
|
+
req = captured[0]
|
|
656
|
+
# urllib stores add_header'd names via .capitalize(); read any case.
|
|
657
|
+
accept_enc = None
|
|
658
|
+
for k, v in req.headers.items():
|
|
659
|
+
if k.lower() == "accept-encoding":
|
|
660
|
+
accept_enc = v
|
|
661
|
+
break
|
|
662
|
+
self.assertEqual(accept_enc, "identity",
|
|
663
|
+
"Accept-Encoding must be 'identity'")
|
|
664
|
+
|
|
665
|
+
def test_403_invalid_session_triggers_refresh(self):
|
|
666
|
+
"""HTTP 403 + INVALID_SESSION_ID body is treated as refreshable."""
|
|
667
|
+
|
|
668
|
+
call_count = {"n": 0}
|
|
669
|
+
|
|
670
|
+
def wrapped():
|
|
671
|
+
call_count["n"] += 1
|
|
672
|
+
if call_count["n"] == 1:
|
|
673
|
+
# 403 with INVALID_SESSION_ID body → should refresh.
|
|
674
|
+
raise urllib.error.HTTPError(
|
|
675
|
+
url="https://example.salesforce.com/x",
|
|
676
|
+
code=403,
|
|
677
|
+
msg="Forbidden",
|
|
678
|
+
hdrs={}, # type: ignore[arg-type]
|
|
679
|
+
fp=io.BytesIO(
|
|
680
|
+
b'[{"errorCode":"INVALID_SESSION_ID","message":"Session expired"}]'
|
|
681
|
+
),
|
|
682
|
+
)
|
|
683
|
+
return {"ok": True}
|
|
684
|
+
|
|
685
|
+
refresh_calls = []
|
|
686
|
+
|
|
687
|
+
def refresh():
|
|
688
|
+
refresh_calls.append(1)
|
|
689
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
690
|
+
|
|
691
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
692
|
+
result = decorated()
|
|
693
|
+
|
|
694
|
+
self.assertEqual(result, {"ok": True})
|
|
695
|
+
self.assertEqual(len(refresh_calls), 1,
|
|
696
|
+
"403+INVALID_SESSION_ID must trigger refresh")
|
|
697
|
+
|
|
698
|
+
def test_403_non_invalid_session_does_not_trigger_refresh(self):
|
|
699
|
+
"""HTTP 403 with some OTHER errorCode propagates unchanged."""
|
|
700
|
+
|
|
701
|
+
def wrapped():
|
|
702
|
+
raise urllib.error.HTTPError(
|
|
703
|
+
url="https://example.salesforce.com/x",
|
|
704
|
+
code=403,
|
|
705
|
+
msg="Forbidden",
|
|
706
|
+
hdrs={}, # type: ignore[arg-type]
|
|
707
|
+
fp=io.BytesIO(
|
|
708
|
+
b'[{"errorCode":"INSUFFICIENT_ACCESS","message":"Denied"}]'
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
refresh_calls = []
|
|
713
|
+
|
|
714
|
+
def refresh():
|
|
715
|
+
refresh_calls.append(1)
|
|
716
|
+
return ("https://example.salesforce.com", "new_tok")
|
|
717
|
+
|
|
718
|
+
decorated = rest_client.retry_on_401(refresh)(wrapped)
|
|
719
|
+
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
720
|
+
decorated()
|
|
721
|
+
|
|
722
|
+
self.assertEqual(ctx.exception.code, 403)
|
|
723
|
+
self.assertEqual(len(refresh_calls), 0,
|
|
724
|
+
"non-auth 403 must NOT burn a refresh")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
class RetryOnTransientHttpTests(unittest.TestCase):
|
|
728
|
+
"""429/503 → exponential-backoff retry up to max_retries."""
|
|
729
|
+
|
|
730
|
+
def test_429_then_success_retries_once(self):
|
|
731
|
+
calls = {"n": 0}
|
|
732
|
+
|
|
733
|
+
def fn():
|
|
734
|
+
calls["n"] += 1
|
|
735
|
+
if calls["n"] == 1:
|
|
736
|
+
raise _make_http_error(429)
|
|
737
|
+
return {"ok": True}
|
|
738
|
+
|
|
739
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
740
|
+
decorated = rest_client.retry_on_transient_http()(fn)
|
|
741
|
+
out = decorated()
|
|
742
|
+
|
|
743
|
+
self.assertEqual(out, {"ok": True})
|
|
744
|
+
self.assertEqual(calls["n"], 2)
|
|
745
|
+
slept.assert_called_once()
|
|
746
|
+
# First retry: base_delay * 2**0 = 1.0
|
|
747
|
+
self.assertAlmostEqual(slept.call_args[0][0], 1.0)
|
|
748
|
+
|
|
749
|
+
def test_503_three_times_propagates_httperror(self):
|
|
750
|
+
"""With max_retries=3, total attempts = 4. Four 503s must surface
|
|
751
|
+
the HTTPError after exactly 3 sleeps."""
|
|
752
|
+
def fn():
|
|
753
|
+
raise _make_http_error(503)
|
|
754
|
+
|
|
755
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
756
|
+
decorated = rest_client.retry_on_transient_http(max_retries=3)(fn)
|
|
757
|
+
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
758
|
+
decorated()
|
|
759
|
+
|
|
760
|
+
self.assertEqual(ctx.exception.code, 503)
|
|
761
|
+
self.assertEqual(slept.call_count, 3,
|
|
762
|
+
"3 sleeps for 3 retries before the final failure")
|
|
763
|
+
|
|
764
|
+
def test_retry_after_header_honored_when_larger(self):
|
|
765
|
+
"""Retry-After: 10 overrides the 1s exponential schedule."""
|
|
766
|
+
def fn():
|
|
767
|
+
exc = urllib.error.HTTPError(
|
|
768
|
+
url="https://example.salesforce.com/x",
|
|
769
|
+
code=429,
|
|
770
|
+
msg="rate limited",
|
|
771
|
+
hdrs=email.message.Message(),
|
|
772
|
+
fp=io.BytesIO(b""),
|
|
773
|
+
)
|
|
774
|
+
exc.headers["Retry-After"] = "10"
|
|
775
|
+
raise exc
|
|
776
|
+
|
|
777
|
+
# fn always raises → exhaust retries; we just need to observe the
|
|
778
|
+
# first sleep value.
|
|
779
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
780
|
+
decorated = rest_client.retry_on_transient_http(max_retries=1)(fn)
|
|
781
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
782
|
+
decorated()
|
|
783
|
+
|
|
784
|
+
self.assertEqual(slept.call_count, 1)
|
|
785
|
+
self.assertAlmostEqual(slept.call_args_list[0][0][0], 10.0,
|
|
786
|
+
msg="Retry-After (10) must win over base_delay (1)")
|
|
787
|
+
|
|
788
|
+
def test_retry_after_smaller_than_base_loses(self):
|
|
789
|
+
"""Retry-After: 0.5 with base_delay=1.0 → sleep 1.0 (base_delay wins)."""
|
|
790
|
+
def fn():
|
|
791
|
+
exc = urllib.error.HTTPError(
|
|
792
|
+
url="https://example.salesforce.com/x",
|
|
793
|
+
code=429,
|
|
794
|
+
msg="rate limited",
|
|
795
|
+
hdrs=email.message.Message(),
|
|
796
|
+
fp=io.BytesIO(b""),
|
|
797
|
+
)
|
|
798
|
+
exc.headers["Retry-After"] = "0.5"
|
|
799
|
+
raise exc
|
|
800
|
+
|
|
801
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
802
|
+
decorated = rest_client.retry_on_transient_http(max_retries=1)(fn)
|
|
803
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
804
|
+
decorated()
|
|
805
|
+
|
|
806
|
+
self.assertAlmostEqual(slept.call_args_list[0][0][0], 1.0)
|
|
807
|
+
|
|
808
|
+
def test_500_propagates_without_sleep(self):
|
|
809
|
+
"""Non-429/503 HTTP errors are out of scope — no retry, no sleep."""
|
|
810
|
+
def fn():
|
|
811
|
+
raise _make_http_error(500)
|
|
812
|
+
|
|
813
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
814
|
+
decorated = rest_client.retry_on_transient_http()(fn)
|
|
815
|
+
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
816
|
+
decorated()
|
|
817
|
+
|
|
818
|
+
self.assertEqual(ctx.exception.code, 500)
|
|
819
|
+
slept.assert_not_called()
|
|
820
|
+
|
|
821
|
+
def test_401_propagates_without_sleep(self):
|
|
822
|
+
"""401 is retry_on_401's job — this decorator must pass it through."""
|
|
823
|
+
def fn():
|
|
824
|
+
raise _make_http_error(401)
|
|
825
|
+
|
|
826
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
827
|
+
decorated = rest_client.retry_on_transient_http()(fn)
|
|
828
|
+
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
829
|
+
decorated()
|
|
830
|
+
|
|
831
|
+
self.assertEqual(ctx.exception.code, 401)
|
|
832
|
+
slept.assert_not_called()
|
|
833
|
+
|
|
834
|
+
def test_stacks_correctly_with_retry_on_401(self):
|
|
835
|
+
"""Integration: 429 → transient retry → 401 → refresh retry → success.
|
|
836
|
+
|
|
837
|
+
This verifies the documented ordering in `tooling_query` /
|
|
838
|
+
`data_query`: transient-HTTP is inner, 401-refresh is outer. If
|
|
839
|
+
the layering were reversed, the refresh would fire on the 429.
|
|
840
|
+
"""
|
|
841
|
+
sequence = ["429", "401", "ok"]
|
|
842
|
+
refresh_calls = []
|
|
843
|
+
|
|
844
|
+
def fn():
|
|
845
|
+
code = sequence.pop(0)
|
|
846
|
+
if code == "429":
|
|
847
|
+
raise _make_http_error(429)
|
|
848
|
+
if code == "401":
|
|
849
|
+
raise _make_http_error(401)
|
|
850
|
+
return {"ok": True}
|
|
851
|
+
|
|
852
|
+
def refresh():
|
|
853
|
+
refresh_calls.append(1)
|
|
854
|
+
return ("https://example.salesforce.com", "new")
|
|
855
|
+
|
|
856
|
+
with mock.patch.object(rest_client.time, "sleep"):
|
|
857
|
+
# Inner: transient retry. Outer: 401 refresh. Matches the
|
|
858
|
+
# stack in tooling_query / data_query.
|
|
859
|
+
decorated = rest_client.retry_on_401(refresh)(
|
|
860
|
+
rest_client.retry_on_transient_http()(fn)
|
|
861
|
+
)
|
|
862
|
+
out = decorated()
|
|
863
|
+
|
|
864
|
+
self.assertEqual(out, {"ok": True})
|
|
865
|
+
self.assertEqual(len(refresh_calls), 1,
|
|
866
|
+
"exactly one refresh — 429 must not burn a refresh")
|
|
867
|
+
self.assertEqual(sequence, [], "all three responses consumed")
|
|
868
|
+
|
|
869
|
+
def test_retry_after_http_date_parses(self):
|
|
870
|
+
"""HTTP-date form of Retry-After is accepted.
|
|
871
|
+
|
|
872
|
+
We pass an RFC 7231 date roughly 5 seconds in the future. The
|
|
873
|
+
parsed delta should be >= 0 and, since base_delay=1.0, the delta
|
|
874
|
+
wins whenever it exceeds 1s. We accept any value >= 1.0 to avoid
|
|
875
|
+
flakiness on slow test machines.
|
|
876
|
+
"""
|
|
877
|
+
future_ts = time.time() + 30 # 30s out, plenty of slack
|
|
878
|
+
http_date = email.utils.formatdate(future_ts, usegmt=True)
|
|
879
|
+
|
|
880
|
+
def fn():
|
|
881
|
+
exc = urllib.error.HTTPError(
|
|
882
|
+
url="https://example.salesforce.com/x",
|
|
883
|
+
code=503,
|
|
884
|
+
msg="unavailable",
|
|
885
|
+
hdrs=email.message.Message(),
|
|
886
|
+
fp=io.BytesIO(b""),
|
|
887
|
+
)
|
|
888
|
+
exc.headers["Retry-After"] = http_date
|
|
889
|
+
raise exc
|
|
890
|
+
|
|
891
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
892
|
+
decorated = rest_client.retry_on_transient_http(max_retries=1)(fn)
|
|
893
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
894
|
+
decorated()
|
|
895
|
+
|
|
896
|
+
self.assertEqual(slept.call_count, 1)
|
|
897
|
+
delay = slept.call_args_list[0][0][0]
|
|
898
|
+
# Sanity: must be larger than base_delay (1.0) and no bigger than
|
|
899
|
+
# the original offset (30) plus generous test-timing slack.
|
|
900
|
+
self.assertGreaterEqual(delay, 1.0)
|
|
901
|
+
self.assertLessEqual(delay, 35.0)
|
|
902
|
+
|
|
903
|
+
def test_malformed_retry_after_falls_back_to_base(self):
|
|
904
|
+
"""`Retry-After: not-a-date` → base_delay wins."""
|
|
905
|
+
def fn():
|
|
906
|
+
exc = urllib.error.HTTPError(
|
|
907
|
+
url="https://example.salesforce.com/x",
|
|
908
|
+
code=429,
|
|
909
|
+
msg="rate limited",
|
|
910
|
+
hdrs=email.message.Message(),
|
|
911
|
+
fp=io.BytesIO(b""),
|
|
912
|
+
)
|
|
913
|
+
exc.headers["Retry-After"] = "not-a-date"
|
|
914
|
+
raise exc
|
|
915
|
+
|
|
916
|
+
with mock.patch.object(rest_client.time, "sleep") as slept:
|
|
917
|
+
decorated = rest_client.retry_on_transient_http(max_retries=1, base_delay=2.0)(fn)
|
|
918
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
919
|
+
decorated()
|
|
920
|
+
|
|
921
|
+
self.assertAlmostEqual(slept.call_args_list[0][0][0], 2.0,
|
|
922
|
+
msg="malformed Retry-After falls back to base_delay")
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
class InvalidSessionDetectionTests(unittest.TestCase):
|
|
926
|
+
"""Unit coverage for the body-inspection helper."""
|
|
927
|
+
|
|
928
|
+
def test_list_with_invalid_session_detected(self):
|
|
929
|
+
body = [{"errorCode": "INVALID_SESSION_ID", "message": "foo"}]
|
|
930
|
+
self.assertTrue(rest_client._body_indicates_invalid_session(body))
|
|
931
|
+
|
|
932
|
+
def test_dict_without_invalid_session_not_detected(self):
|
|
933
|
+
body = {"records": [{"Id": "a"}]}
|
|
934
|
+
self.assertFalse(rest_client._body_indicates_invalid_session(body))
|
|
935
|
+
|
|
936
|
+
def test_none_safe(self):
|
|
937
|
+
self.assertFalse(rest_client._body_indicates_invalid_session(None))
|
|
938
|
+
|
|
939
|
+
def test_string_with_marker(self):
|
|
940
|
+
self.assertTrue(rest_client._body_indicates_invalid_session(
|
|
941
|
+
"...INVALID_SESSION_ID..."
|
|
942
|
+
))
|
|
943
|
+
|
|
944
|
+
# --- BUGFIX 2026-05-03: false positive on ApexClass.Body substring -----
|
|
945
|
+
# Regression: _body_indicates_invalid_session used to walk ALL dict
|
|
946
|
+
# values and substring-match. A 200 OK Tooling Query response whose
|
|
947
|
+
# records carried Apex source referencing the string INVALID_SESSION_ID
|
|
948
|
+
# (e.g. `XCSF_FlowFaultMessage`, `SkillRulesMatchAction`) was
|
|
949
|
+
# misclassified as a session error, triggering spurious Wave B retries
|
|
950
|
+
# and a bogus `INVALID_SESSION_ID after refresh` entry in `_unresolved`.
|
|
951
|
+
|
|
952
|
+
def test_success_body_with_apex_source_substring_not_detected(self):
|
|
953
|
+
"""200 OK Tooling response whose ApexClass.Body mentions
|
|
954
|
+
INVALID_SESSION_ID in source code must NOT be flagged."""
|
|
955
|
+
apex_source = (
|
|
956
|
+
"public class XCSF_FlowFaultMessage {\n"
|
|
957
|
+
" // Handles INVALID_SESSION_ID by rethrowing a typed error.\n"
|
|
958
|
+
" public static void raise(String code) {\n"
|
|
959
|
+
" if (code == 'INVALID_SESSION_ID') {\n"
|
|
960
|
+
" throw new HandledException('session expired');\n"
|
|
961
|
+
" }\n"
|
|
962
|
+
" }\n"
|
|
963
|
+
"}"
|
|
964
|
+
)
|
|
965
|
+
body = {
|
|
966
|
+
"size": 2,
|
|
967
|
+
"totalSize": 2,
|
|
968
|
+
"done": True,
|
|
969
|
+
"queryLocator": None,
|
|
970
|
+
"entityTypeName": "ApexClass",
|
|
971
|
+
"records": [
|
|
972
|
+
{
|
|
973
|
+
"attributes": {
|
|
974
|
+
"type": "ApexClass",
|
|
975
|
+
"url": "/services/data/v66.0/tooling/sobjects/ApexClass/01pUv0",
|
|
976
|
+
},
|
|
977
|
+
"Id": "01pUv000003a1YaIAI",
|
|
978
|
+
"Name": "XCSF_FlowFaultMessage",
|
|
979
|
+
"Body": apex_source,
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
"attributes": {"type": "ApexClass"},
|
|
983
|
+
"Id": "01pUv000003a1YbIAI",
|
|
984
|
+
"Name": "SkillRulesMatchAction",
|
|
985
|
+
"Body": "// references INVALID_SESSION_ID constant",
|
|
986
|
+
},
|
|
987
|
+
],
|
|
988
|
+
}
|
|
989
|
+
self.assertFalse(
|
|
990
|
+
rest_client._body_indicates_invalid_session(body),
|
|
991
|
+
"success payload with INVALID_SESSION_ID inside ApexClass.Body "
|
|
992
|
+
"must not be misclassified as an auth failure",
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
def test_error_envelope_under_errors_key_still_detected(self):
|
|
996
|
+
"""Dict-wrapped error envelope under a known key still flags."""
|
|
997
|
+
body = {
|
|
998
|
+
"errors": [
|
|
999
|
+
{"errorCode": "INVALID_SESSION_ID", "message": "Session expired"}
|
|
1000
|
+
]
|
|
1001
|
+
}
|
|
1002
|
+
self.assertTrue(rest_client._body_indicates_invalid_session(body))
|
|
1003
|
+
|
|
1004
|
+
def test_success_body_with_marker_in_data_field_not_detected(self):
|
|
1005
|
+
"""A top-level data field whose string value mentions the marker
|
|
1006
|
+
must not trigger — only error-envelope keys are walked."""
|
|
1007
|
+
body = {"records": [{"Id": "a", "Description": "handles INVALID_SESSION_ID"}]}
|
|
1008
|
+
self.assertFalse(rest_client._body_indicates_invalid_session(body))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
class QueryWireShapeTests(unittest.TestCase):
|
|
1012
|
+
"""`_query_once` must send GET with a urlencoded `q=`
|
|
1013
|
+
querystring. The prior POST-with-JSON-body shape returned HTTP 405
|
|
1014
|
+
on every real-org run — three reference fixtures failed
|
|
1015
|
+
end-to-end. These tests pin the wire shape so a regression back to
|
|
1016
|
+
POST would break CI before landing.
|
|
1017
|
+
"""
|
|
1018
|
+
|
|
1019
|
+
def _install_capturing_opener(self, captured: list, response_body: bytes):
|
|
1020
|
+
def fake_open(req):
|
|
1021
|
+
captured.append(req)
|
|
1022
|
+
resp = mock.MagicMock()
|
|
1023
|
+
resp.read.return_value = response_body
|
|
1024
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
1025
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
1026
|
+
return resp
|
|
1027
|
+
|
|
1028
|
+
fake_opener = mock.MagicMock()
|
|
1029
|
+
fake_opener.open = fake_open
|
|
1030
|
+
return mock.patch.object(
|
|
1031
|
+
rest_client, "build_opener", return_value=fake_opener
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
def test_data_query_sends_get_with_urlencoded_querystring(self):
|
|
1035
|
+
captured: list = []
|
|
1036
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1037
|
+
soql = "SELECT Id FROM BotDefinition WHERE DeveloperName = 'Foo'"
|
|
1038
|
+
|
|
1039
|
+
with self._install_capturing_opener(captured, body):
|
|
1040
|
+
rest_client.data_query(
|
|
1041
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1042
|
+
soql,
|
|
1043
|
+
api_version="v60.0",
|
|
1044
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
self.assertEqual(len(captured), 1)
|
|
1048
|
+
req = captured[0]
|
|
1049
|
+
# method must be GET. Previously POST.
|
|
1050
|
+
self.assertEqual(req.get_method(), "GET",
|
|
1051
|
+
"REST Query endpoint is GET-only; POST returns 405")
|
|
1052
|
+
# GET has no body.
|
|
1053
|
+
self.assertIsNone(req.data,
|
|
1054
|
+
"GET requests must carry no body")
|
|
1055
|
+
# URL must carry the urlencoded querystring.
|
|
1056
|
+
self.assertIn("/services/data/v60.0/query/?q=", req.full_url)
|
|
1057
|
+
# urllib.parse.urlencode produces `+` for spaces — decoding
|
|
1058
|
+
# should round-trip the SOQL exactly.
|
|
1059
|
+
import urllib.parse
|
|
1060
|
+
parsed = urllib.parse.urlparse(req.full_url)
|
|
1061
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
1062
|
+
self.assertEqual(qs.get("q"), [soql],
|
|
1063
|
+
"urlencoded `q` must round-trip the SOQL exactly")
|
|
1064
|
+
|
|
1065
|
+
def test_tooling_query_sends_get_to_tooling_path(self):
|
|
1066
|
+
captured: list = []
|
|
1067
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1068
|
+
soql = "SELECT Id, DeveloperName FROM BotVersion"
|
|
1069
|
+
|
|
1070
|
+
with self._install_capturing_opener(captured, body):
|
|
1071
|
+
rest_client.tooling_query(
|
|
1072
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1073
|
+
soql,
|
|
1074
|
+
api_version="v60.0",
|
|
1075
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
self.assertEqual(len(captured), 1)
|
|
1079
|
+
req = captured[0]
|
|
1080
|
+
self.assertEqual(req.get_method(), "GET")
|
|
1081
|
+
self.assertIsNone(req.data)
|
|
1082
|
+
# Tooling path is distinct from Data API path.
|
|
1083
|
+
self.assertIn("/services/data/v60.0/tooling/query/?q=", req.full_url)
|
|
1084
|
+
|
|
1085
|
+
def test_query_does_not_send_content_type_header(self):
|
|
1086
|
+
"""GET has no body — Content-Type is inappropriate and was
|
|
1087
|
+
removed alongside the method change. Some edge proxies complain
|
|
1088
|
+
about Content-Type on a bodyless GET; we just drop it."""
|
|
1089
|
+
captured: list = []
|
|
1090
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1091
|
+
|
|
1092
|
+
with self._install_capturing_opener(captured, body):
|
|
1093
|
+
rest_client.data_query(
|
|
1094
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1095
|
+
"SELECT Id FROM Account LIMIT 1",
|
|
1096
|
+
api_version="v60.0",
|
|
1097
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
req = captured[0]
|
|
1101
|
+
content_type = None
|
|
1102
|
+
for k, v in req.headers.items():
|
|
1103
|
+
if k.lower() == "content-type":
|
|
1104
|
+
content_type = v
|
|
1105
|
+
break
|
|
1106
|
+
self.assertIsNone(content_type,
|
|
1107
|
+
"GET requests must not carry Content-Type")
|
|
1108
|
+
|
|
1109
|
+
def test_soql_with_special_chars_is_urlencoded(self):
|
|
1110
|
+
"""SOQL string literals contain single quotes, commas, spaces,
|
|
1111
|
+
parentheses. urlencode must escape them all; a naive concat
|
|
1112
|
+
would break either the URL parse or the SOQL semantics."""
|
|
1113
|
+
captured: list = []
|
|
1114
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1115
|
+
# Exercise the full SOQL character classes that routinely appear.
|
|
1116
|
+
soql = (
|
|
1117
|
+
"SELECT Id, Name FROM ApexClass "
|
|
1118
|
+
"WHERE Name IN ('Foo', 'Bar Baz') "
|
|
1119
|
+
"AND NamespacePrefix IS NULL LIMIT 200"
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
with self._install_capturing_opener(captured, body):
|
|
1123
|
+
rest_client.data_query(
|
|
1124
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1125
|
+
soql,
|
|
1126
|
+
api_version="v60.0",
|
|
1127
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
req = captured[0]
|
|
1131
|
+
import urllib.parse
|
|
1132
|
+
parsed = urllib.parse.urlparse(req.full_url)
|
|
1133
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
1134
|
+
self.assertEqual(qs.get("q"), [soql])
|
|
1135
|
+
# Spaces MUST be escaped — a raw space in a URL is malformed.
|
|
1136
|
+
self.assertNotIn(" ", parsed.query,
|
|
1137
|
+
"querystring must contain no literal spaces")
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
class ApiVersionRequiredKwargTests(unittest.TestCase):
|
|
1141
|
+
"""`api_version` is a REQUIRED keyword-only
|
|
1142
|
+
argument on `tooling_query` + `data_query`.
|
|
1143
|
+
|
|
1144
|
+
The prior design hardcoded `v60.0`. Real orgs (my-org-alias,
|
|
1145
|
+
my-perf-org-alias) run on v66 and expose fields v60 does not — confirmed
|
|
1146
|
+
empirically: `BotDefinition.Description` resolves on v66, raises
|
|
1147
|
+
`INVALID_FIELD` on v60 for the same org. Making `api_version`
|
|
1148
|
+
required surfaces a missed call-site as a TypeError at call time,
|
|
1149
|
+
not a silent regression back to a stale pinned version.
|
|
1150
|
+
"""
|
|
1151
|
+
|
|
1152
|
+
def test_tooling_query_requires_api_version(self):
|
|
1153
|
+
with self.assertRaises(TypeError) as ctx:
|
|
1154
|
+
rest_client.tooling_query(
|
|
1155
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1156
|
+
"SELECT Id FROM ApexClass",
|
|
1157
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1158
|
+
)
|
|
1159
|
+
self.assertIn("api_version", str(ctx.exception))
|
|
1160
|
+
|
|
1161
|
+
def test_data_query_requires_api_version(self):
|
|
1162
|
+
with self.assertRaises(TypeError) as ctx:
|
|
1163
|
+
rest_client.data_query(
|
|
1164
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1165
|
+
"SELECT Id FROM Account LIMIT 1",
|
|
1166
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1167
|
+
)
|
|
1168
|
+
self.assertIn("api_version", str(ctx.exception))
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
class ApiVersionUrlSubstitutionTests(unittest.TestCase):
|
|
1172
|
+
"""the `api_version` kwarg lands in the URL path
|
|
1173
|
+
unchanged. A caller passing `v66.0` must produce
|
|
1174
|
+
`/services/data/v66.0/...` in the outbound Request, NOT the old
|
|
1175
|
+
hardcoded `v60.0`.
|
|
1176
|
+
|
|
1177
|
+
Regression shield: if a future refactor re-pins the version string,
|
|
1178
|
+
these tests fail at the wire-shape assertion even when unit-level
|
|
1179
|
+
behavior of `tooling_query` still looks correct.
|
|
1180
|
+
"""
|
|
1181
|
+
|
|
1182
|
+
def _install_capturing_opener(self, captured: list, response_body: bytes):
|
|
1183
|
+
def fake_open(req):
|
|
1184
|
+
captured.append(req)
|
|
1185
|
+
resp = mock.MagicMock()
|
|
1186
|
+
resp.read.return_value = response_body
|
|
1187
|
+
resp.__enter__ = mock.MagicMock(return_value=resp)
|
|
1188
|
+
resp.__exit__ = mock.MagicMock(return_value=False)
|
|
1189
|
+
return resp
|
|
1190
|
+
|
|
1191
|
+
fake_opener = mock.MagicMock()
|
|
1192
|
+
fake_opener.open = fake_open
|
|
1193
|
+
return mock.patch.object(
|
|
1194
|
+
rest_client, "build_opener", return_value=fake_opener
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
def test_tooling_query_v66_in_url(self):
|
|
1198
|
+
captured: list = []
|
|
1199
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1200
|
+
with self._install_capturing_opener(captured, body):
|
|
1201
|
+
rest_client.tooling_query(
|
|
1202
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1203
|
+
"SELECT Id FROM ApexClass",
|
|
1204
|
+
api_version="v66.0",
|
|
1205
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1206
|
+
)
|
|
1207
|
+
self.assertEqual(len(captured), 1)
|
|
1208
|
+
url = captured[0].full_url
|
|
1209
|
+
self.assertIn("/services/data/v66.0/tooling/query/?q=", url)
|
|
1210
|
+
# And the old hardcoded version must NOT appear — otherwise the
|
|
1211
|
+
# substitution silently failed.
|
|
1212
|
+
self.assertNotIn("v60.0", url)
|
|
1213
|
+
|
|
1214
|
+
def test_data_query_v66_in_url(self):
|
|
1215
|
+
captured: list = []
|
|
1216
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1217
|
+
with self._install_capturing_opener(captured, body):
|
|
1218
|
+
rest_client.data_query(
|
|
1219
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1220
|
+
"SELECT Id FROM BotDefinition LIMIT 1",
|
|
1221
|
+
api_version="v66.0",
|
|
1222
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1223
|
+
)
|
|
1224
|
+
url = captured[0].full_url
|
|
1225
|
+
self.assertIn("/services/data/v66.0/query/?q=", url)
|
|
1226
|
+
self.assertNotIn("v60.0", url)
|
|
1227
|
+
|
|
1228
|
+
def test_different_api_version_per_call(self):
|
|
1229
|
+
"""Two calls with different `api_version` land on distinct URLs —
|
|
1230
|
+
proves the value is NOT cached in module-level state between
|
|
1231
|
+
invocations."""
|
|
1232
|
+
captured: list = []
|
|
1233
|
+
body = json.dumps({"records": []}).encode("utf-8")
|
|
1234
|
+
with self._install_capturing_opener(captured, body):
|
|
1235
|
+
rest_client.tooling_query(
|
|
1236
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1237
|
+
"SELECT Id FROM ApexClass",
|
|
1238
|
+
api_version="v60.0",
|
|
1239
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1240
|
+
)
|
|
1241
|
+
rest_client.tooling_query(
|
|
1242
|
+
rest_client.static_creds("https://example.salesforce.com", "tok"),
|
|
1243
|
+
"SELECT Id FROM ApexClass",
|
|
1244
|
+
api_version="v66.0",
|
|
1245
|
+
on_401_refresh=lambda: ("https://example.salesforce.com", "new"),
|
|
1246
|
+
)
|
|
1247
|
+
self.assertEqual(len(captured), 2)
|
|
1248
|
+
self.assertIn("/services/data/v60.0/tooling/query/", captured[0].full_url)
|
|
1249
|
+
self.assertIn("/services/data/v66.0/tooling/query/", captured[1].full_url)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
if __name__ == "__main__":
|
|
1253
|
+
unittest.main()
|