From c1573f1a975c38c72c6ae15bc0ad591e3fbd51c0 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 14:00:21 -0400 Subject: [PATCH] Read saved Web Search settings from their nested path in V2 admin The V2 Admin Settings page keys unsaved edits by each field's flat key, but the Web Search Foundry connection is saved inside web_search_agent. Test web search, field visibility, section status and the secret field all read those keys as top-level settings, so a saved connection was tested with blanks and the server reported the endpoint, API version and agent ID as required. Add readStoredFieldValue and readSettingValue, which follow a field's declared paths or settings_path, move the connection-test payload into buildConnectionTestPayload, and pass the field index to the connection test, the section shell and the section status. The section shell's visibility filter had no index at all, which also hid the document action limits once their action was saved as enabled. Bump to 0.261.260 with a regression test that runs the real field schema, the TypeScript readers and the server-side Web Search test together. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../src/components/admin/ConnectionTest.tsx | 50 +- .../src/components/admin/SettingsSection.tsx | 26 +- application/v2_ui/src/lib/adminFields.ts | 109 +++- application/v2_ui/src/lib/adminSections.ts | 34 +- .../v2_ui/src/pages/AdminSettingsPage.tsx | 10 +- ...V2_ADMIN_WEB_SEARCH_NESTED_SETTINGS_FIX.md | 99 ++++ docs/explanation/release_notes.md | 10 + .../test_v2_admin_nested_setting_reads.mjs | 68 +++ .../test_v2_admin_nested_setting_reads.py | 467 ++++++++++++++++++ .../test_v2_admin_secret_field_handling.py | 14 +- .../test_v2_admin_section_logic.ts | 96 +++- 12 files changed, 901 insertions(+), 84 deletions(-) create mode 100644 docs/explanation/fixes/V2_ADMIN_WEB_SEARCH_NESTED_SETTINGS_FIX.md create mode 100644 functional_tests/test_v2_admin_nested_setting_reads.mjs create mode 100644 functional_tests/test_v2_admin_nested_setting_reads.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 670ab4808..3a890675e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.259" +VERSION = "0.261.260" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/admin/ConnectionTest.tsx b/application/v2_ui/src/components/admin/ConnectionTest.tsx index 95f7a93dc..c300f1be2 100644 --- a/application/v2_ui/src/components/admin/ConnectionTest.tsx +++ b/application/v2_ui/src/components/admin/ConnectionTest.tsx @@ -5,10 +5,11 @@ // out is to try them. Without this, configuring a connection means saving blind and // waiting for a user to hit the failure. // -// The test runs against the values currently on screen, not the saved ones, so a -// connection can be verified before it is committed. The one value the browser cannot -// supply is a stored credential: it holds only the redaction placeholder, and the server -// swaps that back for the real secret before making the call. +// The test runs against the values currently on screen, unsaved edits included, so a +// connection can be verified before it is committed. A saved value is read from wherever +// its field declares it is stored, which is not always a top-level key. The one value the +// browser cannot supply is a stored credential: it holds only the redaction placeholder, +// and the server swaps that back for the real secret before making the call. // // Nothing here knows about a specific service. Which test to run and how to shape its // payload are both read from the field definition, so a new connection test is a schema @@ -18,7 +19,7 @@ import { useState } from 'react'; import { clsx } from 'clsx'; import { CheckCircle2, Loader2, PlugZap, XCircle } from 'lucide-react'; import { ApiError, api } from '../../lib/apiClient'; -import { evaluateDependency, type AdminField } from '../../lib/adminFields'; +import { buildConnectionTestPayload, type AdminField } from '../../lib/adminFields'; import type { Json } from '../../lib/types'; /** What the shared connection-test dispatcher returns. Shapes vary by test. */ @@ -37,27 +38,6 @@ interface TestOutcome { guidance: string[]; } -/** - * Write a value at a dotted path, creating intermediate objects. - * - * Payload shapes are nested -- `direct.endpoint`, `apim.subscription_key` -- because that - * is what the existing test handlers expect. Declaring them flat and expanding here keeps - * the schema readable. - */ -function assignPath(target: Json, path: string, value: unknown): void { - const parts = path.split('.'); - let cursor = target as Record; - - for (const part of parts.slice(0, -1)) { - if (typeof cursor[part] !== 'object' || cursor[part] === null) { - cursor[part] = {}; - } - cursor = cursor[part] as Record; - } - - cursor[parts[parts.length - 1]] = value; -} - /** * Turn a response into something worth showing. * @@ -85,19 +65,19 @@ export function ConnectionTest({ field, settings, draft, + fieldsByKey, disabled, }: { field: AdminField; settings: Json; draft: Json; + /** Every declared field by key, so a value saved at a nested path is found. */ + fieldsByKey: Map; disabled?: boolean; }) { const [running, setRunning] = useState(false); const [outcome, setOutcome] = useState(null); - const read = (key: string): unknown => - Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : settings[key]; - const run = async () => { if (!field.test_type) { return; @@ -106,17 +86,7 @@ export function ConnectionTest({ setRunning(true); setOutcome(null); - const payload: Json = { test_type: field.test_type }; - for (const [path, source] of Object.entries(field.test_payload ?? {})) { - // A payload entry can be conditional, which is how one declaration covers - // both sides of an APIM-or-direct choice without sending the branch that is - // not in use. - if (!evaluateDependency(source.when, read)) { - continue; - } - const value = source.key !== undefined ? read(source.key) : source.value; - assignPath(payload, path, value ?? ''); - } + const payload = buildConnectionTestPayload(field, settings, draft, fieldsByKey); try { const response = await api.post( diff --git a/application/v2_ui/src/components/admin/SettingsSection.tsx b/application/v2_ui/src/components/admin/SettingsSection.tsx index aee198871..b6ee42140 100644 --- a/application/v2_ui/src/components/admin/SettingsSection.tsx +++ b/application/v2_ui/src/components/admin/SettingsSection.tsx @@ -102,6 +102,14 @@ export interface SettingsSectionProps { * settings while a search narrows `fields` to the matches. */ hierarchyFields?: AdminField[]; + /** + * Every declared field by key. + * + * A field saved at a nested path is found only through its declaration, so without + * this a gate stored that way -- the Web Search authentication type, say -- reads as + * unset and hides the fields that depend on it. + */ + fieldsByKey?: Map; /** Force every group open, used while a search is filtering the page. */ forceExpanded?: boolean; /** Opt-in presentation overrides; never changes the schema's behavior. */ @@ -283,6 +291,7 @@ export function SettingsSection({ status: statusProp, icon, hierarchyFields, + fieldsByKey, renderField, renderCapability, forceExpanded, @@ -297,8 +306,8 @@ export function SettingsSection({ ); const derivedStatus = useMemo( - () => computeSectionStatus(fields, settings, draft, statusRule), - [statusRule, fields, settings, draft], + () => computeSectionStatus(fields, settings, draft, statusRule, fieldsByKey), + [statusRule, fields, settings, draft, fieldsByKey], ); const status = statusProp ?? derivedStatus; @@ -308,16 +317,19 @@ export function SettingsSection({ ); const capabilityOn = capability?.key - ? asBoolean(readSectionValue(settings, draft, capability.key)) + ? asBoolean(readSectionValue(settings, draft, capability.key, fieldsByKey)) : true; // A section states each distinct prerequisite once, at the top, rather than repeating // it on every field that carries it. - const requirements = useMemo(() => collectRequirements(fields, settings, draft), [fields, settings, draft]); + const requirements = useMemo( + () => collectRequirements(fields, settings, draft, fieldsByKey), + [fields, settings, draft, fieldsByKey], + ); const groups = useMemo( - () => groupFields(bodyFields.filter((field) => isFieldVisible(field, settings, draft))), - [bodyFields, settings, draft], + () => groupFields(bodyFields.filter((field) => isFieldVisible(field, settings, draft, fieldsByKey))), + [bodyFields, settings, draft, fieldsByKey], ); const presentation = presentSectionStatus(status); @@ -432,7 +444,7 @@ export function SettingsSection({ ))} diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts index 308b9bc5b..083110b64 100644 --- a/application/v2_ui/src/lib/adminFields.ts +++ b/application/v2_ui/src/lib/adminFields.ts @@ -419,17 +419,7 @@ export function readFieldValue(field: AdminField, settings: Json, draft: Json): return draft[field.key]; } - // Two descriptors name a nested home: `settings_path` as segments, `paths` as - // dotted strings. The draft is always keyed flat, because that is what the PATCH - // sends and what the server unpacks; only the stored read follows the path. - let stored: unknown; - if (field.settings_path) { - stored = readNestedValue(settings, field.settings_path); - } else if (field.paths?.length) { - stored = readNestedSetting(settings, field.paths[0]); - } else { - stored = settings[field.key]; - } + const stored = readStoredFieldValue(field, settings); if (stored !== undefined && stored !== null && field.scale) { // Stored in one unit, edited in another. File Sync's per-run limit is held @@ -442,6 +432,26 @@ export function readFieldValue(field: AdminField, settings: Json, draft: Json): return stored === undefined || stored === null ? field.default : stored; } +/** + * Read a field's saved value from where it is actually stored. + * + * Two descriptors name a nested home: `settings_path` as segments, `paths` as dotted + * strings. The draft is always keyed flat, because that is what the PATCH sends and what + * the server unpacks; only the stored read follows the path. + * + * Kept apart from `readFieldValue` because the connection tests, the dependency checks + * and the section status need the same lookup without the display default. + */ +export function readStoredFieldValue(field: AdminField, settings: Json): unknown { + if (field.settings_path) { + return readNestedValue(settings, field.settings_path); + } + if (field.paths?.length) { + return readNestedSetting(settings, field.paths[0]); + } + return field.key ? settings[field.key] : undefined; +} + /** Walk a dotted path into the settings document, or undefined if it is not there. */ export function readNestedSetting(settings: Json, path: string): unknown { return readNestedValue(settings, String(path || '').split('.')); @@ -591,13 +601,21 @@ export function buildFieldIndex(schema: AdminFieldSchema): Map, ): boolean { return !field.legacy && evaluateDependency(field.depends_on, (key) => - readDependencyValue(key, settings, draft, fieldsByKey), + readSettingValue(key, settings, draft, fieldsByKey), runtimeFlags); } @@ -703,6 +721,57 @@ function dependencyValueMatches(current: unknown, expected: boolean | string): b return asString(current).trim() === String(expected); } +/** + * Write a value at a dotted path, creating intermediate objects. + * + * Payload shapes are nested -- `direct.endpoint`, `apim.subscription_key` -- because that + * is what the existing test handlers expect. Declaring them flat and expanding here keeps + * the schema readable. + */ +function assignPath(target: Json, path: string, value: unknown): void { + const parts = path.split('.'); + let cursor = target as Record; + + for (const part of parts.slice(0, -1)) { + if (typeof cursor[part] !== 'object' || cursor[part] === null) { + cursor[part] = {}; + } + cursor = cursor[part] as Record; + } + + cursor[parts[parts.length - 1]] = value; +} + +/** + * Build the request body a `connection-test` field declares, from the values on screen. + * + * Each source is read as an unsaved edit first and then as the saved value, found + * wherever its field declares it is stored. The Web Search Foundry connection is saved + * inside `web_search_agent`, so reading its keys as top-level settings would test a + * saved connection with blanks. + * + * A payload entry can be conditional, which is how one declaration covers both sides of + * an APIM-or-direct choice without sending the branch that is not in use. + */ +export function buildConnectionTestPayload( + field: AdminField, + settings: Json, + draft: Json, + fieldsByKey: Map, +): Json { + const read = (key: string): unknown => readSettingValue(key, settings, draft, fieldsByKey); + + const payload: Json = { test_type: field.test_type }; + for (const [path, source] of Object.entries(field.test_payload ?? {})) { + if (!evaluateDependency(source.when, read)) { + continue; + } + const value = source.key !== undefined ? read(source.key) : source.value; + assignPath(payload, path, value ?? ''); + } + return payload; +} + /** * Whether a field's cross-section prerequisite is satisfied. * @@ -745,7 +814,7 @@ export function isSectionVisible( if (Object.prototype.hasOwnProperty.call(runtimeFlags, condition)) { return runtimeFlags[condition]; } - return asBoolean(readDependencyValue(condition, settings, draft, fieldsByKey)); + return asBoolean(readSettingValue(condition, settings, draft, fieldsByKey)); } /** diff --git a/application/v2_ui/src/lib/adminSections.ts b/application/v2_ui/src/lib/adminSections.ts index b79baa42e..e0005ff7a 100644 --- a/application/v2_ui/src/lib/adminSections.ts +++ b/application/v2_ui/src/lib/adminSections.ts @@ -12,6 +12,7 @@ import { evaluateDependency, evaluateSectionStatus, readFieldGroup, + readSettingValue, type AdminField, type AdminFieldDependency, type AdminFieldRequirement, @@ -28,9 +29,19 @@ import type { Json } from './types'; */ export type SectionStatus = 'off' | 'blocked' | 'incomplete' | 'ready' | 'none'; -/** Read a field's current value, preferring an unsaved edit over the stored one. */ -export function readSectionValue(settings: Json, draft: Json, key: string): unknown { - return Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : settings[key]; +/** + * Read a field's current value, preferring an unsaved edit over the stored one. + * + * Given the field index, a value saved at a nested path is found where its field + * declares it, so a configured Web Search connection does not read as blank. + */ +export function readSectionValue( + settings: Json, + draft: Json, + key: string, + fieldsByKey?: Map, +): unknown { + return readSettingValue(key, settings, draft, fieldsByKey); } /** @@ -69,11 +80,16 @@ export function findCapabilityField(fields: AdminField[]): AdminField | undefine * Deduplicated by key, because a prerequisite usually applies to several fields and * stating it once at the top reads better than repeating it on each. */ -export function collectRequirements(fields: AdminField[], settings?: Json, draft: Json = {}): AdminFieldRequirement[] { +export function collectRequirements( + fields: AdminField[], + settings?: Json, + draft: Json = {}, + fieldsByKey?: Map, +): AdminFieldRequirement[] { const seen = new Map(); for (const field of fields) { if (settings && field.role !== 'capability') { - const read = (key: string) => readSectionValue(settings, draft, key); + const read = (key: string) => readSectionValue(settings, draft, key, fieldsByKey); if (!evaluateDependency(field.depends_on, read)) continue; if (field.type === 'switch' && field.key && !asBoolean(read(field.key) ?? field.default)) continue; } @@ -96,11 +112,12 @@ export function deriveSectionStatus( fields: AdminField[], settings: Json, draft: Json, + fieldsByKey?: Map, ): SectionStatus { - const read = (key: string) => readSectionValue(settings, draft, key); + const read = (key: string) => readSectionValue(settings, draft, key, fieldsByKey); const capability = findCapabilityField(fields); - const unmet = collectRequirements(fields, settings, draft).some( + const unmet = collectRequirements(fields, settings, draft, fieldsByKey).some( (requirement) => !asBoolean(read(requirement.key)), ); if (unmet) { @@ -146,12 +163,13 @@ export function computeSectionStatus( settings: Json, draft: Json, statusRule?: AdminSectionStatusRule, + fieldsByKey?: Map, ): SectionStatus { const declared = evaluateSectionStatus(statusRule, settings, draft); if (declared) { return DECLARED_STATUS_MAP[declared]; } - return deriveSectionStatus(fields, settings, draft); + return deriveSectionStatus(fields, settings, draft, fieldsByKey); } /** How a field sits relative to the switch that governs it. */ diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx index f5354af7b..1f92be731 100644 --- a/application/v2_ui/src/pages/AdminSettingsPage.tsx +++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx @@ -85,6 +85,7 @@ import { isRequirementSatisfied, isSectionVisible, readFieldValue, + readStoredFieldValue, type AdminField, type AdminSettingsPatchResponse, type AdminSettingsResponse, @@ -493,11 +494,12 @@ export function AdminSettingsPage() { settings, draft, sectionStatus[section.sectionId], + fieldsByKey, ), ); } return statuses; - }, [sections, settings, draft, sectionStatus]); + }, [sections, settings, draft, sectionStatus, fieldsByKey]); /** The page index follows the same filters as the cards. */ const indexEntries = useMemo( @@ -828,7 +830,9 @@ export function AdminSettingsPage() { value={value} // The saved value, not the draft: only that says whether a credential // exists, which is what tells an empty box apart from a pending delete. - storedValue={field.key ? settings[field.key] : undefined} + // Read from where the field is stored, which for the Web Search client + // secret is inside `web_search_agent` rather than under its own key. + storedValue={readStoredFieldValue(field, settings)} error={error} warning={warning} disabled={saving} @@ -890,6 +894,7 @@ export function AdminSettingsPage() { field={field} settings={settings} draft={draft} + fieldsByKey={fieldsByKey} disabled={saving} /> ); @@ -1370,6 +1375,7 @@ export function AdminSettingsPage() { icon={resolveAdminNavIcon(section.icon)} fields={section.fields} hierarchyFields={section.allFields} + fieldsByKey={fieldsByKey} settings={settings} draft={draft} // Sections that describe a status rule use it; diff --git a/docs/explanation/fixes/V2_ADMIN_WEB_SEARCH_NESTED_SETTINGS_FIX.md b/docs/explanation/fixes/V2_ADMIN_WEB_SEARCH_NESTED_SETTINGS_FIX.md new file mode 100644 index 000000000..f301371af --- /dev/null +++ b/docs/explanation/fixes/V2_ADMIN_WEB_SEARCH_NESTED_SETTINGS_FIX.md @@ -0,0 +1,99 @@ +# V2 admin Web Search nested settings fix (0.261.260) + +## Issue + +In the V2 Admin Settings page, **Test web search** failed for a Web Search connection that was already saved, even though every value was on screen: + +> Web Search test could not start because required settings are missing or invalid. +> +> - Foundry Project Endpoint is required. +> - Foundry API Version is required. +> - Foundry Agent ID is required. + +The test only worked while the values were unsaved edits. After **Save** or a page reload, it failed again. + +The same section had three related symptoms: + +- **Managed Identity Type** stayed hidden while the saved authentication type was Managed Identity. With a saved Service Principal, the Tenant ID, Client ID, Client Secret and Cloud fields stayed hidden. +- The section read as not configured, so its connection group opened as though it needed attention. +- Clearing a saved Service Principal client secret showed no "will be removed when you save" warning and no **Undo**. + +The same blind spot also affected the **Document action capabilities** card under Agents & Actions. Its chat and workflow limits for Analyze, Comparison and Merge stayed hidden after their action was saved as enabled. + +## Root cause + +The V2 page keeps unsaved edits under each field's flat key, such as `web_search_foundry_endpoint`. The Web Search Foundry connection is not saved under those keys. The field schema declares a `paths` storage location inside `web_search_agent`, for example `web_search_agent.other_settings.azure_ai_foundry.endpoint`, and the save handler writes the values there. + +Only the input renderer, `readFieldValue`, followed `paths`. Every other reader treated the key as a top-level setting: + +| Reader | Before | Effect | +| --- | --- | --- | +| `ConnectionTest` payload builder | Read the draft, then `settings[key]`. | Saved Foundry values were sent as blanks, and the server rejected the test. | +| Dependency reader for field visibility | Followed `settings_path` but not `paths`. | Fields gated on the saved authentication type were hidden. | +| `SettingsSection` visibility filter | Received no field index. | A gate stored at any nested path read as unset, including the `settings_path` flags that gate the document action limits. | +| Section status helpers | Read `settings[key]`. | Required Foundry fields read as empty, so the section showed as incomplete. | +| `SecretField` saved-value check | Read `settings[field.key]`. | The saved client secret was not recognized. | + +The server-side Web Search test, secret resolution and save handling were already correct. + +## Version + +Fixed in version: **0.261.260**, recorded by `VERSION` in `application\single_app\config.py`. + +## Technical details + +### Files modified + +- `application/v2_ui/src/lib/adminFields.ts` +- `application/v2_ui/src/lib/adminSections.ts` +- `application/v2_ui/src/components/admin/ConnectionTest.tsx` +- `application/v2_ui/src/components/admin/SettingsSection.tsx` +- `application/v2_ui/src/pages/AdminSettingsPage.tsx` +- `application/single_app/config.py` + +### Code changes + +`adminFields.ts` now has one stored-value lookup, `readStoredFieldValue`. It follows `settings_path`, then the first `paths` entry, and otherwise reads the top-level key. `readFieldValue` uses it, so the input renderer behaves as before. + +`readSettingValue` replaces the private dependency reader and is exported. It reads an unsaved edit first. Otherwise, it reads the saved value through the field that declares the key. Like the reader it replaces, it does not substitute the schema default, so a condition holds only once a value is saved or chosen. + +The payload logic moved out of `ConnectionTest` into `buildConnectionTestPayload`, a pure function that reads every source through `readSettingValue`. `ConnectionTest` now requires the field index, so TypeScript rejects a call site that omits it. + +`readSectionValue`, `collectRequirements`, `deriveSectionStatus` and `computeSectionStatus` accept an optional field index. `SettingsSection` accepts it as an optional prop and uses it for visibility, status and prerequisites. `AdminSettingsPage` passes its global index to the section shell, the connection test and the status calculation. It also gives `SecretField` the value saved at the field's storage path. + +### Impact + +Two areas change behavior: + +- **Web Search connection fields.** These are the only `paths` fields referenced by a dependency or connection-test payload whose storage path differs from the field key. The URL Access lists are also referenced, but their first storage path is their own key, so they read the same value as before. +- **Document action limits.** The page already resolved their `settings_path` gates, but the section shell dropped the limits again because it had no field index. They now appear while their action is enabled, as the schema declares. + +No settings document, API route or server-side validation changed. + +## Validation + +### Tests + +- `functional_tests/test_v2_admin_nested_setting_reads.py`, with `test_v2_admin_nested_setting_reads.mjs`, runs this end to end: + 1. Saves a Foundry connection through the real `normalize_admin_settings_updates`. + 2. Masks the saved secret with `redact_admin_settings_secrets_for_api`, as the V2 settings response does. + 3. Runs the real TypeScript readers against the real field schema. + 4. Sends the resulting payload through `run_web_search_connection_test`. + + It covers managed identity and service principal connections, field visibility, section status, the saved client secret and an unsaved edit. It also checks that the old top-level read produces the exact three errors shown above. +- `functional_tests/test_v2_admin_section_logic.ts` adds checks for section status and `SettingsSection` visibility with gates saved through both `paths` and `settings_path`. +- `functional_tests/test_v2_admin_secret_field_handling.py` now expects `SecretField` to receive the value saved at the field's storage path. + +Removing `paths` support from `readStoredFieldValue` fails 6 of the 8 end-to-end checks. Removing the field index from `SettingsSection`'s visibility filter fails the new shell check. + +### Before and after + +| Scenario | Before | After | +| --- | --- | --- | +| Test a saved managed identity connection | Endpoint, API version and agent ID reported as required | Saved values reach the Foundry agent call | +| Test a saved service principal connection | Endpoint, API version, agent ID and authentication type sent blank | Tested as a service principal; the server resolves the masked secret | +| Saved authentication type is Managed Identity | Managed Identity Type hidden | Managed Identity Type shown | +| Saved authentication type is Service Principal | Credential fields hidden | Tenant ID, Client ID, Client Secret and Cloud shown | +| Section status with a saved connection | Incomplete | Configured | +| Clearing a saved client secret | No removal warning | Removal warning and **Undo** shown | +| Document action enabled and saved | Chat and workflow limits hidden | Limits shown | diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index ebc32df8c..9bc331292 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.260)** + +#### Bug Fixes + +* **V2 Web Search Test Uses the Saved Foundry Connection** + * **Test web search** in V2 Admin Settings reported "Foundry Project Endpoint is required", "Foundry API Version is required" and "Foundry Agent ID is required" for a saved connection whose values were on screen. The Foundry connection is saved inside `web_search_agent`, but the test read each field as a top-level setting, so it only worked before the values were saved. It now reads the saved values, including a service principal's authentication type and masked client secret, which the server swaps for the stored secret. + * The same fix shows **Managed Identity Type**, or the service principal fields, for the saved authentication type, and marks a saved connection as configured. Clearing a saved client secret now shows the removal warning and **Undo**. + * The **Document action capabilities** card now shows the chat and workflow limits for Analyze, Comparison and Merge while that action is enabled. The section card used to hide them once the setting was saved. + * (Ref: `adminFields.ts` `readSettingValue` and `buildConnectionTestPayload`, `adminSections.ts`, `ConnectionTest.tsx`, `SettingsSection.tsx`, `AdminSettingsPage.tsx`, `test_v2_admin_nested_setting_reads.py`, [V2 Admin Web Search Nested Settings Fix](fixes/V2_ADMIN_WEB_SEARCH_NESTED_SETTINGS_FIX.md)) + ### **(v0.261.259)** #### Bug Fixes diff --git a/functional_tests/test_v2_admin_nested_setting_reads.mjs b/functional_tests/test_v2_admin_nested_setting_reads.mjs new file mode 100644 index 000000000..5709b994b --- /dev/null +++ b/functional_tests/test_v2_admin_nested_setting_reads.mjs @@ -0,0 +1,68 @@ +// test_v2_admin_nested_setting_reads.mjs +// +// Companion to test_v2_admin_nested_setting_reads.py. +// Version: 0.261.260 +// Implemented in: 0.261.260 +// +// Runs the real V2 admin readers against the real field schema for each scenario the +// Python driver supplies, and prints what they produced as JSON. The driver owns the +// assertions, because the connection-test payload computed here is handed on to the +// server-side Web Search validator, which only Python can run. +// +// Run through the driver: `python functional_tests/test_v2_admin_nested_setting_reads.py`. +// Requires Node 22.6 or newer, which strips the TypeScript types so the real modules are +// imported. + +import { readFileSync } from 'node:fs'; + +import './test_support/tsResolve.mjs'; + +const { + buildConnectionTestPayload, + buildFieldIndex, + isFieldVisible, + readStoredFieldValue, +} = await import('../application/v2_ui/src/lib/adminFields.ts'); + +const { computeSectionStatus } = await import('../application/v2_ui/src/lib/adminSections.ts'); + +const input = JSON.parse(readFileSync(process.argv[2], 'utf8')); +const fieldsByKey = buildFieldIndex(input.schema); +const sectionFields = input.schema[input.section_id] ?? []; +const testField = sectionFields.find((field) => field.component === 'connection-test'); +const secretField = sectionFields.find((field) => field.type === 'secret'); + +if (!testField) { + console.error(`${input.section_id} declares no connection-test component.`); + process.exit(2); +} + +const results = {}; +for (const [name, { settings, draft }] of Object.entries(input.scenarios)) { + // The page drops hidden fields before it computes a status, so this does the same. + const visibleFields = sectionFields.filter((field) => + isFieldVisible(field, settings, draft, fieldsByKey), + ); + + results[name] = { + payload: buildConnectionTestPayload(testField, settings, draft, fieldsByKey), + // An empty index reads every key as a top-level setting, which is what the + // connection test did before the fix. + flat_payload: buildConnectionTestPayload(testField, settings, draft, new Map()), + visible: Object.fromEntries( + sectionFields + .filter((field) => field.key) + .map((field) => [field.key, visibleFields.includes(field)]), + ), + status: computeSectionStatus( + visibleFields, + settings, + draft, + input.status_rule ?? undefined, + fieldsByKey, + ), + stored_secret: secretField ? (readStoredFieldValue(secretField, settings) ?? null) : null, + }; +} + +process.stdout.write(JSON.stringify(results)); diff --git a/functional_tests/test_v2_admin_nested_setting_reads.py b/functional_tests/test_v2_admin_nested_setting_reads.py new file mode 100644 index 000000000..0b85d9a3f --- /dev/null +++ b/functional_tests/test_v2_admin_nested_setting_reads.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +# test_v2_admin_nested_setting_reads.py +""" +Functional test for V2 admin reads of settings saved at a nested path. +Version: 0.261.260 +Implemented in: 0.261.260 + +The V2 Admin Settings page keys unsaved edits by each field's flat key, but a few +settings are saved inside a nested object. The Web Search Foundry connection is the +one that broke: it is saved inside ``web_search_agent``, so once it had been saved, +"Test web search" read ``web_search_foundry_endpoint`` as a top-level key, found +nothing, and the server answered "Foundry Project Endpoint is required" with the +endpoint plainly on screen. The same blind spot hid the fields gated on the saved +authentication type, left the section reading as incomplete, and dropped the warning +shown before a saved client secret is cleared. + +This test saves a Foundry connection through the real normalizer, masks it the way +the settings GET does, runs the real TypeScript readers against the real field schema +through ``test_v2_admin_nested_setting_reads.mjs``, and hands the resulting +connection-test payload to the real server-side Web Search test. A copy of any of +those would prove nothing about the code that ships. +""" + +import copy +import importlib.util +import json +import re +import shutil +import subprocess +import sys +import tempfile +import types +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent)) + +from test_support.app_stubs import import_app_module +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +PAGE_TSX = REPO_ROOT / "application" / "v2_ui" / "src" / "pages" / "AdminSettingsPage.tsx" +COMPANION = Path(__file__).resolve().with_suffix(".mjs") + +SECTION_ID = "web-search-section" +MINIMUM_NODE = (22, 6) + +ENDPOINT = "https://contoso-foundry.services.ai.azure.com/api/projects/web-search" +EDITED_ENDPOINT = "https://fabrikam-foundry.services.ai.azure.com/api/projects/web-search" +API_VERSION = "v1" +AGENT_ID = "asst_nestedReadCheck" +TENANT_ID = "00000000-0000-0000-0000-000000000001" +CLIENT_ID = "00000000-0000-0000-0000-000000000002" +CLIENT_SECRET = "nested-read-check-secret" + +MANAGED_IDENTITY = { + "web_search_foundry_endpoint": ENDPOINT, + "web_search_foundry_api_version": API_VERSION, + "web_search_foundry_agent_id": AGENT_ID, + "web_search_foundry_auth_type": "managed_identity", + "web_search_foundry_managed_identity_type": "system_assigned", +} + +SERVICE_PRINCIPAL = { + "web_search_foundry_endpoint": ENDPOINT, + "web_search_foundry_api_version": API_VERSION, + "web_search_foundry_agent_id": AGENT_ID, + "web_search_foundry_auth_type": "service_principal", + "web_search_foundry_tenant_id": TENANT_ID, + "web_search_foundry_client_id": CLIENT_ID, + "web_search_foundry_client_secret": CLIENT_SECRET, + "web_search_foundry_cloud": "", +} + +SERVICE_PRINCIPAL_KEYS = ( + "web_search_foundry_tenant_id", + "web_search_foundry_client_id", + "web_search_foundry_client_secret", + "web_search_foundry_cloud", +) + +_MISSING = object() +_READER_RESULTS = {} + +fields_module = import_app_module("admin_settings_fields") +secret_utils = import_app_module("admin_settings_secret_utils") + + +def save_connection(updates): + """A settings document holding a Foundry connection as the V2 save stores it.""" + normalized, errors, _warnings = fields_module.normalize_admin_settings_updates( + updates, {} + ) + assert not errors, f"The Foundry connection did not normalize: {errors}" + return {"enable_web_search": True, "web_search_consent_accepted": True, **normalized} + + +def as_sent_to_browser(document): + """Mask stored secrets the way the settings GET does before the page sees them.""" + return secret_utils.redact_admin_settings_secrets_for_api(document) + + +def secret_storage_path(): + """Where the Web Search client secret is stored, as the schema declares it.""" + for field in fields_module.get_admin_settings_fields()[SECTION_ID]: + if field.get("key") == "web_search_foundry_client_secret": + return field["paths"][0] + raise AssertionError("The Web Search client secret field is not declared.") + + +def build_scenarios(): + saved_identity = as_sent_to_browser(save_connection(MANAGED_IDENTITY)) + return { + "managed_identity": {"settings": saved_identity, "draft": {}}, + "service_principal": { + "settings": as_sent_to_browser(save_connection(SERVICE_PRINCIPAL)), + "draft": {}, + }, + "edited_endpoint": { + "settings": saved_identity, + "draft": {"web_search_foundry_endpoint": EDITED_ENDPOINT}, + }, + "not_configured": { + "settings": {"enable_web_search": True, "web_search_consent_accepted": True}, + "draft": {}, + }, + } + + +def node_executable(): + """The Node binary when it can strip TypeScript types, otherwise None.""" + node = shutil.which("node") + if not node: + return None + version = subprocess.run([node, "--version"], capture_output=True, text=True) + try: + installed = tuple(int(part) for part in version.stdout.strip().lstrip("v").split(".")[:2]) + except ValueError: + return None + return node if installed >= MINIMUM_NODE else None + + +def reader_results(): + """What the TypeScript readers produce for each scenario, or None to skip. + + Computed once and shared, because every check reads the same run. + """ + if "results" in _READER_RESULTS: + return _READER_RESULTS["results"] + + node = node_executable() + if not node: + print(" skip Node 22.6 or newer is required to run the TypeScript readers") + _READER_RESULTS["results"] = None + return None + + request = { + "section_id": SECTION_ID, + "schema": fields_module.get_admin_settings_fields(), + "status_rule": fields_module.get_admin_section_status().get(SECTION_ID), + "scenarios": build_scenarios(), + } + with tempfile.TemporaryDirectory() as workdir: + request_path = Path(workdir) / "scenarios.json" + request_path.write_text(json.dumps(request), encoding="utf-8") + result = subprocess.run( + [node, str(COMPANION), str(request_path)], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + + if result.returncode != 0: + print(result.stdout) + print(result.stderr) + raise AssertionError("The TypeScript readers did not run.") + + _READER_RESULTS["results"] = json.loads(result.stdout) + return _READER_RESULTS["results"] + + +def load_web_search_test_module(): + """Load functions_web_search_test.py with its runtime dependencies stubbed. + + The Foundry runtime and Semantic Kernel are replaced only for the duration of the + import; the module keeps the stand-ins it bound, and nothing else sees them. + """ + + class FoundryAgentInvocationError(RuntimeError): + pass + + class ChatMessageContent: + def __init__(self, role, content): + self.role = role + self.content = content + + foundry = types.ModuleType("foundry_agent_runtime") + foundry.FoundryAgentInvocationError = FoundryAgentInvocationError + foundry.execute_foundry_agent = lambda **kwargs: None + + appinsights = types.ModuleType("functions_appinsights") + appinsights.log_event = lambda *args, **kwargs: None + + chat_message = types.ModuleType("semantic_kernel.contents.chat_message_content") + chat_message.ChatMessageContent = ChatMessageContent + + stubs = { + "foundry_agent_runtime": foundry, + "functions_appinsights": appinsights, + "semantic_kernel": types.ModuleType("semantic_kernel"), + "semantic_kernel.contents": types.ModuleType("semantic_kernel.contents"), + "semantic_kernel.contents.chat_message_content": chat_message, + } + originals = {name: sys.modules.get(name, _MISSING) for name in stubs} + sys.modules.update(stubs) + try: + spec = importlib.util.spec_from_file_location( + "functions_web_search_test_nested_reads", + APP_ROOT / "functions_web_search_test.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + for name, original in originals.items(): + if original is _MISSING: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + + +def run_server_test(payload): + """Run the server-side Web Search test with an agent that records its settings.""" + module = load_web_search_test_module() + captured = {} + + def record_agent_call(**kwargs): + captured.update(kwargs) + return types.SimpleNamespace( + message="Microsoft - https://www.microsoft.com", + citations=[{"url": "https://www.microsoft.com"}], + model="test-model", + ) + + response, status_code = module.run_web_search_connection_test( + copy.deepcopy(payload), + global_settings={}, + execute_agent=record_agent_call, + ) + return response, status_code, captured + + +def test_a_saved_connection_lives_inside_web_search_agent(): + """The premise: the saved values are nested, so a top-level read finds nothing.""" + print("Testing where the V2 save stores the Foundry connection...") + + assert_app_version_at_least("0.261.260") + + stored = save_connection(MANAGED_IDENTITY) + foundry = fields_module.read_nested_setting( + stored, "web_search_agent.other_settings.azure_ai_foundry" + ) + assert isinstance(foundry, dict), "The Foundry connection was not saved in web_search_agent." + assert foundry.get("endpoint") == ENDPOINT + assert foundry.get("agent_id") == AGENT_ID + + flat_keys = sorted(key for key in stored if key.startswith("web_search_foundry_")) + assert not flat_keys, f"The connection was also saved under top-level keys: {flat_keys}" + + print(" The connection is saved inside web_search_agent and nowhere else.") + return True + + +def test_the_connection_test_sends_the_saved_values(): + """A saved connection is tested with the values on screen, not with blanks.""" + print("\nTesting the connection-test payload for a saved managed identity...") + + results = reader_results() + if results is None: + return True + + managed = results["managed_identity"] + foundry = managed["payload"]["foundry"] + assert foundry["endpoint"] == ENDPOINT, f"Endpoint sent as {foundry['endpoint']!r}" + assert foundry["api_version"] == API_VERSION + assert foundry["agent_id"] == AGENT_ID + assert foundry["authentication_type"] == "managed_identity" + assert foundry["managed_identity_type"] == "system_assigned" + assert "client_secret" not in foundry, ( + "A service principal credential was sent for a managed identity connection." + ) + + response, status_code, captured = run_server_test(managed["payload"]) + assert status_code == 200, f"The server rejected the saved connection: {response}" + assert captured["foundry_settings"]["endpoint"] == ENDPOINT + assert captured["foundry_settings"]["agent_id"] == AGENT_ID + + # What the button sent before the fix, and the answer the administrator saw. + flat_response, flat_status, _captured = run_server_test(managed["flat_payload"]) + assert flat_status == 400, f"A blank connection was accepted: {flat_response}" + for message in ( + "Foundry Project Endpoint is required.", + "Foundry API Version is required.", + "Foundry Agent ID is required.", + ): + assert message in flat_response["guidance"], flat_response + + print(" The saved endpoint, API version and agent ID reach the Foundry agent call.") + return True + + +def test_a_saved_service_principal_is_tested_as_one(): + """Reading the saved auth type as blank would test a service principal as an identity.""" + print("\nTesting the connection-test payload for a saved service principal...") + + results = reader_results() + if results is None: + return True + + foundry = results["service_principal"]["payload"]["foundry"] + assert foundry["authentication_type"] == "service_principal" + assert foundry["tenant_id"] == TENANT_ID + assert foundry["client_id"] == CLIENT_ID + assert "managed_identity_type" not in foundry + + # The browser only holds the mask. The route swaps it for the stored secret, read + # from the same nested path the field declares. + assert foundry["client_secret"] == fields_module.SECRET_REDACTED_VALUE + stored = save_connection(SERVICE_PRINCIPAL) + resolved = secret_utils.resolve_admin_settings_secret_value( + secret_storage_path(), foundry["client_secret"], stored + ) + assert resolved == CLIENT_SECRET, "The masked client secret did not resolve." + + payload = copy.deepcopy(results["service_principal"]["payload"]) + payload["foundry"]["client_secret"] = resolved + response, status_code, captured = run_server_test(payload) + assert status_code == 200, f"The server rejected the saved connection: {response}" + assert captured["foundry_settings"]["authentication_type"] == "service_principal" + assert captured["foundry_settings"]["client_secret"] == CLIENT_SECRET + + print(" The saved service principal is tested with its own credentials.") + return True + + +def test_fields_gated_on_the_saved_auth_type_are_shown(): + """A gate saved at a nested path still decides which fields are on screen.""" + print("\nTesting visibility of fields gated on the saved authentication type...") + + results = reader_results() + if results is None: + return True + + managed = results["managed_identity"]["visible"] + assert managed["web_search_foundry_managed_identity_type"] is True, ( + "Managed Identity Type is hidden although Managed Identity is the saved choice." + ) + for key in SERVICE_PRINCIPAL_KEYS: + assert managed[key] is False, f"{key} is shown for a managed identity connection." + + principal = results["service_principal"]["visible"] + for key in SERVICE_PRINCIPAL_KEYS: + assert principal[key] is True, f"{key} is hidden although Service Principal is saved." + assert principal["web_search_foundry_managed_identity_type"] is False + # Only a custom cloud needs an authority, and the saved cloud is Azure Public. + assert principal["web_search_foundry_authority"] is False + + print(" The fields for the saved authentication type are the ones shown.") + return True + + +def test_a_saved_connection_reads_as_configured(): + """The section status reads the same saved values the controls show.""" + print("\nTesting the Web Search section status...") + + results = reader_results() + if results is None: + return True + + assert results["managed_identity"]["status"] == "ready" + assert results["service_principal"]["status"] == "ready" + assert results["not_configured"]["status"] == "incomplete" + + print(" A saved connection reads as configured; an empty one still does not.") + return True + + +def test_a_saved_client_secret_is_recognized(): + """The secret field must know a credential is saved to warn before clearing it.""" + print("\nTesting the stored value behind the client secret field...") + + results = reader_results() + if results is None: + return True + + assert results["service_principal"]["stored_secret"] == fields_module.SECRET_REDACTED_VALUE + assert results["managed_identity"]["stored_secret"] is None + + print(" The saved client secret is recognized at its nested path.") + return True + + +def test_an_unsaved_edit_still_wins(): + """Testing before saving is the point, so an edit outranks the saved value.""" + print("\nTesting that an unsaved edit is what the connection test sends...") + + results = reader_results() + if results is None: + return True + + foundry = results["edited_endpoint"]["payload"]["foundry"] + assert foundry["endpoint"] == EDITED_ENDPOINT + assert foundry["agent_id"] == AGENT_ID, "The saved values should fill in the rest." + + print(" The edited endpoint is sent, with the saved values alongside it.") + return True + + +def test_the_page_hands_the_field_index_to_every_reader(): + """The readers only find a nested value when the page passes them the index.""" + print("\nTesting that the page wires the field index through...") + + page = PAGE_TSX.read_text(encoding="utf-8") + + for component in ("SettingsSection", "ConnectionTest"): + assert re.search( + rf"<{component}\b[^>]*?fieldsByKey=\{{fieldsByKey\}}", page, re.DOTALL + ), f"AdminSettingsPage should pass fieldsByKey to {component}." + + assert "storedValue={readStoredFieldValue(field, settings)}" in page, ( + "The secret field should be told what is saved at the field's storage path." + ) + assert re.search( + r"computeSectionStatus\(\s*section\.allFields,[^)]*fieldsByKey,\s*\)", page, re.DOTALL + ), "The section status should be computed with the field index." + + print(" The connection test, section shell, status and secret field get the index.") + return True + + +if __name__ == "__main__": + tests = [ + test_a_saved_connection_lives_inside_web_search_agent, + test_the_connection_test_sends_the_saved_values, + test_a_saved_service_principal_is_tested_as_one, + test_fields_gated_on_the_saved_auth_type_are_shown, + test_a_saved_connection_reads_as_configured, + test_a_saved_client_secret_is_recognized, + test_an_unsaved_edit_still_wins, + test_the_page_hands_the_field_index_to_every_reader, + ] + + results = [] + for test in tests: + try: + results.append(bool(test())) + except Exception as exc: + print(f"FAILED {test.__name__}: {exc}") + import traceback + + traceback.print_exc() + results.append(False) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_v2_admin_secret_field_handling.py b/functional_tests/test_v2_admin_secret_field_handling.py index d5e53ebac..1f1237c6a 100644 --- a/functional_tests/test_v2_admin_secret_field_handling.py +++ b/functional_tests/test_v2_admin_secret_field_handling.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for masking and restoring secrets on the V2 admin settings API. -Version: 0.261.122 +Version: 0.261.260 Implemented in: 0.261.059 +Updated in: 0.261.260 Admin Settings is the one surface that edits credentials, so it cannot use ``sanitize_settings_for_user``, which removes those keys entirely. The @@ -332,11 +333,14 @@ def test_the_control_distinguishes_untouched_from_pending_delete(): "credential:\n " + "\n ".join(missing) ) - # The page must supply the stored value, or the control cannot make the call. + # The page must supply the stored value, or the control cannot make the call. It is + # read from where the field is stored: the Web Search client secret is saved inside + # `web_search_agent`, not under its own key. page = PAGE_MODULE.read_text(encoding="utf-8") - assert re.search(r"storedValue=\{field\.key \? settings\[field\.key\]", page), ( - "AdminSettingsPage no longer passes the stored value to SecretField, so the " - "control cannot tell a configured credential from an unconfigured one." + assert re.search(r"storedValue=\{readStoredFieldValue\(field, settings\)\}", page), ( + "AdminSettingsPage no longer passes the saved value to SecretField from the " + "field's storage path, so the control cannot tell a configured credential " + "from an unconfigured one." ) print(" Untouched, replacing and pending-delete are distinguishable.") diff --git a/functional_tests/test_v2_admin_section_logic.ts b/functional_tests/test_v2_admin_section_logic.ts index 6a5dec64b..d3682cb3d 100644 --- a/functional_tests/test_v2_admin_section_logic.ts +++ b/functional_tests/test_v2_admin_section_logic.ts @@ -1,10 +1,11 @@ // test_v2_admin_section_logic.ts // // Runtime test for the Admin Settings section shell's presentation decisions. -// Version: 0.261.258 +// Version: 0.261.260 // Implemented in: 0.261.084 // Agents-only visual hierarchy coverage added in: 0.261.093 // Every-section presentation and schema-derived hierarchy added in: 0.261.258 +// Values saved at a nested path covered in: 0.261.260 // // The V2 admin surface used to render a section as a flat run of controls in declaration // order. That is fine for Appearance. It is not fine for Knowledge, where Document @@ -45,6 +46,7 @@ import { shouldGroupStartOpen, } from '../application/v2_ui/src/lib/adminSections'; import { + buildFieldIndex, groupFields, evaluateDependency, type AdminField, @@ -594,6 +596,98 @@ check('distinct subsection presentation preserves collapsed defaults and counts' assert.deepEqual(calls, []); }); +/** + * A connection saved inside a container, declared the way Web Search's is. + * + * The draft is keyed flat, but the saved values live under `nested_agent`, so a reader + * that treats each key as a top-level setting finds nothing once the values are saved. + */ +const NESTED_SETTINGS = { + enable_nested: true, + nested_agent: { + connection: { + endpoint: 'https://example.invalid', + authentication_type: 'managed_identity', + enabled: true, + }, + }, +}; + +check('a required value saved inside a container reads as configured', () => { + const fields = [ + capability('enable_nested'), + required('nested_endpoint', { paths: ['nested_agent.connection.endpoint'] }), + ]; + const fieldsByKey = buildFieldIndex({ 'nested-section': fields }); + + assert.equal(deriveSectionStatus(fields, NESTED_SETTINGS, {}, fieldsByKey), 'ready'); + assert.equal(computeSectionStatus(fields, NESTED_SETTINGS, {}, undefined, fieldsByKey), 'ready'); + // An unsaved edit still decides the status before the save lands. + assert.equal( + deriveSectionStatus(fields, NESTED_SETTINGS, { nested_endpoint: '' }, fieldsByKey), + 'incomplete', + ); +}); + +check('the shell shows a field gated on a value saved inside a container', () => { + // Both nested descriptors: `paths`, as the Web Search connection declares, and + // `settings_path`, as the document action limits declare. + const fields: AdminField[] = [ + capability('enable_nested'), + { + key: 'nested_auth_type', + type: 'select', + label: 'Authentication Type', + paths: ['nested_agent.connection.authentication_type'], + }, + { + key: 'nested_identity_type', + type: 'select', + label: 'Managed Identity Type', + paths: ['nested_agent.connection.managed_identity_type'], + depends_on: { key: 'nested_auth_type', equals: 'managed_identity' }, + }, + { + key: 'nested_enabled', + type: 'switch', + label: 'Enabled', + settings_path: ['nested_agent', 'connection', 'enabled'], + }, + { + key: 'nested_limit', + type: 'number', + label: 'Limit', + settings_path: ['nested_agent', 'connection', 'limit'], + depends_on: { key: 'nested_enabled', equals: true }, + }, + ]; + const calls: string[] = []; + renderToStaticMarkup(createElement(SettingsSection, { + sectionId: 'nested-section', + label: 'Nested', + groupLabel: 'Group', + tabLabel: '', + fields, + settings: NESTED_SETTINGS, + draft: {}, + fieldsByKey: buildFieldIndex({ 'nested-section': fields }), + renderField: (field) => { + calls.push(`field:${field.key}`); + return createElement('span', { key: field.key }, field.label); + }, + renderCapability: () => null, + })); + + assert.ok( + calls.includes('field:nested_identity_type'), + 'the identity type is hidden although the authentication type it depends on is saved', + ); + assert.ok( + calls.includes('field:nested_limit'), + 'the limit is hidden although the flag it depends on is saved', + ); +}); + let passed = 0; for (const [name, fn] of checks) { try {