diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json index 2cce2ac5..e2adafb8 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Initialization_Workflow.json @@ -1393,12 +1393,19 @@ "where": "@and(not(equals(item(), null)), not(equals(item(), '')))" } }, - "Compose_Google_Protected_Update_Body": { + "Compose_Google_Update_Parent_After_Removal": { "runAfter": { "Filter_Google_Nonempty_Update_Values": [ "Succeeded" ] }, "type": "Compose", - "inputs": "@if(empty(body('Filter_Google_Nonempty_Update_Values')), if(contains(items('ForEach_Google_User_Target')['source'], '.'), if(contains(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.'))), setProperty(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.')), removeProperty(coalesce(variables('googleUpdateUserBody')?[first(split(items('ForEach_Google_User_Target')['source'], '.'))], json('{}')), last(split(items('ForEach_Google_User_Target')['source'], '.')))), variables('googleUpdateUserBody')), removeProperty(variables('googleUpdateUserBody'), items('ForEach_Google_User_Target')['source'])), if(contains(outputs('Compose_Dynamic_ArrayProperties'), items('ForEach_Google_User_Target')['source']), setProperty(variables('googleUpdateUserBody'), items('ForEach_Google_User_Target')['source'], body('Filter_Google_Nonempty_Update_Values')), variables('googleUpdateUserBody')))" + "inputs": "@if(contains(items('ForEach_Google_User_Target')['source'], '.'), removeProperty(coalesce(variables('googleUpdateUserBody')?[first(split(items('ForEach_Google_User_Target')['source'], '.'))], json('{}')), last(split(items('ForEach_Google_User_Target')['source'], '.'))), json('{}'))" + }, + "Compose_Google_Protected_Update_Body": { + "runAfter": { + "Compose_Google_Update_Parent_After_Removal": [ "Succeeded" ] + }, + "type": "Compose", + "inputs": "@if(empty(body('Filter_Google_Nonempty_Update_Values')), if(contains(items('ForEach_Google_User_Target')['source'], '.'), if(contains(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.'))), if(and(contains(coalesce(variables('googleUpdateUserBody')?[first(split(items('ForEach_Google_User_Target')['source'], '.'))], json('{}')), last(split(items('ForEach_Google_User_Target')['source'], '.'))), empty(outputs('Compose_Google_Update_Parent_After_Removal'))), removeProperty(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.'))), setProperty(variables('googleUpdateUserBody'), first(split(items('ForEach_Google_User_Target')['source'], '.')), outputs('Compose_Google_Update_Parent_After_Removal'))), variables('googleUpdateUserBody')), removeProperty(variables('googleUpdateUserBody'), items('ForEach_Google_User_Target')['source'])), if(contains(outputs('Compose_Dynamic_ArrayProperties'), items('ForEach_Google_User_Target')['source']), setProperty(variables('googleUpdateUserBody'), items('ForEach_Google_User_Target')['source'], body('Filter_Google_Nonempty_Update_Values')), variables('googleUpdateUserBody')))" }, "Set_Google_Protected_Update_Body": { "runAfter": { @@ -1454,6 +1461,7 @@ "sourceToTargetLookup": "@variables('sourceToTargetLookup')", "googleRequiredUserTargets": "@variables('googleRequiredUserTargets')", "googleSkippedUpdateSources": "@variables('googleSkippedUpdateSources')", + "googleUpdateBodyEmptiedByFiltering": "@and(contains(toLower(coalesce(first(body('Filter_BaseAddress_Connectivity_Parameter'))?['value'], '')), 'googleapis.com'), not(empty(outputs('Compose_Final_Dynamic_Update_User_Body'))), equals(variables('googleUpdateUserBody'), json('{}')), not(empty(variables('googleSkippedUpdateSources'))))", "mappedUserTargetAttributes": "@body('Select_Target_Attribute_Names')", "mappedGroupTargetAttributes": "@body('Select_Target_Group_Attribute_Names')", "targetDirectoryUserAttributes": "@outputs('Compose_Target_User_Attributes')", diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json index 3c6ae5f5..f6179887 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json @@ -410,6 +410,22 @@ "uri": "@body('Get_SCIMTests_Output')?['properties']?['outputsLink']?['uri']" } }, + "Compose_Pagination_Model_Results": { + "runAfter": { + "Get_SCIMTests_Output_Content": [ + "Succeeded", + "Failed", + "Skipped", + "TimedOut" + ] + }, + "type": "Compose", + "inputs": { + "userPaginationResult": "@if(and(contains(toLower(coalesce(body('Call_Initialization_Workflow')?['baseAddress'], '')), 'googleapis.com'), or(equals(parameters('EnabledTests'), 'All'), equals(parameters('EnabledTests'), 'SCIMTests'), equals(parameters('EnabledTests'), 'SCIM_User_Pagination_Test')), equals(body('Get_SCIMTests_Output_Content')?['SCIMUserPaginationTestOutputs']?['overallResult'], 'SKIPPED')), 'PASSED', coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserPaginationTestOutputs']?['overallResult'], 'FAILED'))", + "groupPaginationResult": "@if(and(contains(toLower(coalesce(body('Call_Initialization_Workflow')?['baseAddress'], '')), 'googleapis.com'), or(equals(parameters('EnabledTests'), 'All'), equals(parameters('EnabledTests'), 'SCIMTests'), equals(parameters('EnabledTests'), 'SCIM_Group_Pagination_Test')), equals(body('Get_SCIMTests_Output_Content')?['SCIMGroupPaginationTestOutputs']?['overallResult'], 'SKIPPED')), 'PASSED', coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupPaginationTestOutputs']?['overallResult'], 'FAILED'))", + "googleSkipExceptionReason": "@if(contains(toLower(coalesce(body('Call_Initialization_Workflow')?['baseAddress'], '')), 'googleapis.com'), 'Google pagination exception: enabled pagination tests explicitly reported as SKIPPED are accepted as PASSED only in aggregate results. Individual results remain SKIPPED; failed or missing results are not exempted.', null)" + } + }, "Evaluate_Test_Results": { "runAfter": { "Compose_Initialization_RunLink": [ @@ -430,11 +446,8 @@ "Skipped", "TimedOut" ], - "Get_SCIMTests_Output_Content": [ - "Succeeded", - "Failed", - "Skipped", - "TimedOut" + "Compose_Pagination_Model_Results": [ + "Succeeded" ] }, "type": "Compose", @@ -505,11 +518,12 @@ "GroupTests_Workflow": "@outputs('Compose_GroupTests_RunLink')", "SCIMTests_Workflow": "@outputs('Compose_SCIMTests_RunLink')" }, - "overallLogicAppResult": "@if(or(not(equals(coalesce(body('Get_UserTests_Output_Content')?['CreateUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['UpdateUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['DisableUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isManagerAttributeSupported'],false),not(equals(coalesce(body('Get_UserTests_Output_Content')?['ManagerTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_UserTests_Output_Content')?['RestoreUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMNullUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['CreateGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['UpdateGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['AddMemberTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['RemoveMemberTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SchemaDiscoverabilityTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserCreateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupCreateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserPaginationTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['PODUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['PODGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),equals(actions('Call_Initialization_Workflow')?['status'],'Failed'),equals(coalesce(body('Poll_UserTests_Status')?['properties']?['status'],''),'Failed'),equals(coalesce(body('Poll_GroupTests_Status')?['properties']?['status'],''),'Failed'),or(not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'],'FAILED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isManagerAttributeSupported'],false),contains(string(coalesce(body('Call_Initialization_Workflow')?['targetDirectoryUserAttributes'],json('[]'))),'manager'),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUpdateManagerTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))))),'Failed',if(equals(coalesce(body('Poll_SCIMTests_Status')?['properties']?['status'],''),'Failed'),'Failed','Succeeded'))", - "optionalSuccessTestsResult": "@if(or(not(equals(coalesce(body('Get_UserTests_Output_Content')?['DeleteUserTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED')), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_GroupTests_Output_Content')?['DeleteGroupTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED'))), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_GroupTests_Output_Content')?['RestoreGroupTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED'))), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupPaginationTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED')))), 'WARNING', 'PASSED')", + "overallLogicAppResult": "@if(or(not(equals(coalesce(body('Get_UserTests_Output_Content')?['CreateUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['UpdateUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['DisableUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isManagerAttributeSupported'],false),not(equals(coalesce(body('Get_UserTests_Output_Content')?['ManagerTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_UserTests_Output_Content')?['RestoreUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMNullUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['CreateGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['UpdateGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['AddMemberTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['RemoveMemberTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SchemaDiscoverabilityTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserCreateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupCreateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMGroupUpdateTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),not(equals(coalesce(outputs('Compose_Pagination_Model_Results')?['userPaginationResult'],'FAILED'),'PASSED')),not(equals(coalesce(body('Get_UserTests_Output_Content')?['PODUserTestOutputs']?['overallResult'],'SKIPPED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'],false),not(equals(coalesce(body('Get_GroupTests_Output_Content')?['PODGroupTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))),equals(actions('Call_Initialization_Workflow')?['status'],'Failed'),equals(coalesce(body('Poll_UserTests_Status')?['properties']?['status'],''),'Failed'),equals(coalesce(body('Poll_GroupTests_Status')?['properties']?['status'],''),'Failed'),or(not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'],'FAILED'),'PASSED')),and(coalesce(body('Call_Initialization_Workflow')?['isManagerAttributeSupported'],false),contains(string(coalesce(body('Call_Initialization_Workflow')?['targetDirectoryUserAttributes'],json('[]'))),'manager'),not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUpdateManagerTestOutputs']?['overallResult'],'SKIPPED'),'PASSED'))))),'Failed',if(equals(coalesce(body('Poll_SCIMTests_Status')?['properties']?['status'],''),'Failed'),'Failed','Succeeded'))", + "optionalSuccessTestsResult": "@if(or(not(equals(coalesce(body('Get_UserTests_Output_Content')?['DeleteUserTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED')), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_GroupTests_Output_Content')?['DeleteGroupTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED'))), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(body('Get_GroupTests_Output_Content')?['RestoreGroupTestOutputs']?['overallResult'], 'SKIPPED'), 'PASSED'))), and(coalesce(body('Call_Initialization_Workflow')?['isGroupSupported'], false), not(equals(coalesce(outputs('Compose_Pagination_Model_Results')?['groupPaginationResult'], 'FAILED'), 'PASSED')))), 'WARNING', 'PASSED')", "authenticationModelResult": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'], 'FAILED')", "authenticationModelBypassed": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelBypassed'], false)", "authenticationModelBypassReason": "@body('Get_SCIMTests_Output_Content')?['authenticationModelBypassReason']", + "paginationModelResults": "@outputs('Compose_Pagination_Model_Results')", "validationCompletedAtUtc": "@utcNow()" } }, @@ -757,6 +771,7 @@ "authenticationModelResult": "@outputs('Evaluate_Test_Results')?['authenticationModelResult']", "authenticationModelBypassed": "@outputs('Evaluate_Test_Results')?['authenticationModelBypassed']", "authenticationModelBypassReason": "@outputs('Evaluate_Test_Results')?['authenticationModelBypassReason']", + "paginationModelResults": "@outputs('Evaluate_Test_Results')?['paginationModelResults']", "overallResult": "@outputs('Evaluate_Test_Results')?['overallLogicAppResult']", "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')", "LogicAppVersion": "@parameters('LogicAppVersion')", diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md index 99523212..0a1ff259 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md @@ -69,19 +69,19 @@ Tests that don't apply are reported as **SKIPPED** (not failures). However, grou | 20 | **SCIM_Group_Update_Test** | mandatory | Direct `PATCH /Groups/{id}` with attribute updates (single-valued, non-reference, excluding id/members/schemas/meta). | PATCH returns 200/204; GET returns 200/204; all PATCHed attributes match; zero mismatches. | | 21 | **Validate_Credentials_Test** | mandatory when selected | OAuth 2.0 Client Credentials flow — acquires a token from the ISV's `scimTokenEndpoint`, sends credentials in the required `Header` or `Body` location, includes the optional space-delimited scope string, then validates SCIM access with the token. **Skipped** when this method is not selected. | Token endpoint returns 2xx with `access_token`; token validity between 60–360 minutes; SCIM endpoint returns 2xx when called with the token. | | 22 | **Federated_Identity_Test** | mandatory when selected, except Google below | Workload identity federation — acquires Entra ID token, then exchanges it for ISV token via one of three flows: Google Service Account, Google STS, or generic federated endpoint. **Skipped** when unselected or covered by the temporary Google federation exception below. | Entra token acquisition returns 2xx with `access_token`; federated token exchange returns 2xx with valid token. Supports Google SA flow (STS exchange → SA impersonation), Google STS flow (direct exchange), and generic flow (client assertion). For the Google exception, diagnostics stay SKIPPED and only the aggregate authentication result is marked PASSED. | -| 23 | **SCIM_User_Pagination_Test** | mandatory | Ensures ≥11 users exist (creates throwaway users if needed), then paginates `GET /Users?startIndex=N&count=5` across pages. Cleans up created users afterward. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected value (`page * 5 + 1`); zero failed page verifications. | -| 24 | **SCIM_Group_Pagination_Test** | optional | Same as user pagination but for `/Groups`. Failure = WARNING. **Skipped** if groups not supported. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected; zero failed verifications. | +| 23 | **SCIM_User_Pagination_Test** | mandatory, except Google below | Ensures ≥11 users exist (creates throwaway users if needed), then paginates `GET /Users?startIndex=N&count=5` across pages. Cleans up created users afterward. **Skipped** for Google endpoints. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected value (`page * 5 + 1`); zero failed page verifications. Google skips use the aggregate exception below. | +| 24 | **SCIM_Group_Pagination_Test** | optional | Same as user pagination but for `/Groups`. Failure = WARNING. **Skipped** if groups not supported or for Google endpoints. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected; zero failed verifications. Google skips use the aggregate exception below. | | 25 | **SCIM_Update_Manager_Test** | mandatory | Direct SCIM PATCH test for manager attribute: set (Add), change (Replace), remove (Replace with empty). **Skipped** if `manager` not in target directory attributes. | Each PATCH returns 200/204; GET after each operation confirms manager value matches expected; all three operations (set/change/remove) succeed. | --- ## Test Categories Summary -The existing post-create and post-PATCH propagation waits in `SCIMTests_Workflow.json` are 30 seconds before dependent operations or verification reads. These waits cover null updates, user and group updates, and manager updates to allow asynchronously updated attributes to become visible. +The post-create and post-PATCH propagation waits in `SCIMTests_Workflow.json` are 30 seconds before dependent operations or verification reads, except `Delay_SCIM_Update_User_After_Patch`, which waits 90 seconds before verifying the direct user update. The longer wait accommodates observed propagation delays of approximately 60-65 seconds under concurrent full-suite load. Other null-update, group-update, and manager-update waits remain unchanged. For connections whose saved `baseAddress` contains `googleapis.com` (case-insensitive), initialization reads `/Schemas` and identifies mapped required attributes and subattributes, also honoring `targetSchema.required`. `SCIM_Null_Update_Test` excludes those targets from both empty-string and null PATCH payloads and their verification lists. For example, a required `emails[type eq "other"].value` is not cleared; optional sibling fields and optional address or enterprise-extension fields remain eligible. Clearing a whole complex attribute is also excluded when it contains a subattribute declared required by either `/Schemas` or another mapping's `targetSchema.required`, even if the parent is optional. The same parent protection applies to blank mapped sources in the Entra update payload. Schema URNs are matched separately, so a required extension attribute does not exclude an optional core attribute with the same name. -The Google Entra update payload is protected separately: blank/null source values (including blank array entries) mapped to required targets are omitted, while populated updates, `false`, and `0` are retained. Omitted sources are listed in initialization's `googleSkippedUpdateSources` and excluded from update-value comparisons. For Google connections only, the Entra update step rejects an entirely empty update body before calling Graph, reporting a validation failure rather than sending an empty PATCH. Other providers retain their previous update-request behavior, including sending an empty body if one is produced. This changes the validation workflow's Graph PATCH, not the provisioning service's saved mappings or future production behavior. Manager-removal lifecycle phases are unchanged. +The Google Entra update payload is protected separately: blank/null source values (including blank array entries) mapped to required targets are omitted, while populated updates, `false`, and `0` are retained. Removing the last mapped field from a nested object also removes its now-empty parent; populated siblings and unrelated objects are preserved. Omitted sources are listed in initialization's `googleSkippedUpdateSources` and excluded from update-value comparisons. If filtering removes all properties from a previously nonempty Google update body, initialization records `googleUpdateBodyEmptiedByFiltering`. After successful create/provisioning checks, `Update_User_Test` reports `PASSED` by this exception, with its existing result message explaining that no update PATCH or update verification was executed. An originally empty, null, or otherwise unqualified Google update body still fails validation before Graph PATCH; create/provisioning failures are never exempted. Other providers retain their previous update-request behavior, including sending an empty body if one is produced. This changes the validation workflow's Graph PATCH, not the provisioning service's saved mappings or future production behavior. Manager-removal lifecycle phases are unchanged. If the Google null test has no eligible targets, neither clearing PATCH is sent. Its summary explicitly reports `clearingPhasesSkipped`, the reason, excluded required targets, and zero verified attributes. Successful setup can still pass; HTTP or verification failures are not exempted. Other providers retain their existing attribute-selection behavior. @@ -136,6 +136,12 @@ The standalone diagnostic acquires a tenant-app token; it cannot represent Micro Google connections selecting `OAuth2ClientCredentials`, non-Google connections, missing base addresses, and invalid method selections do not receive this exception. Standalone tests have not been made globally optional. +#### Google pagination aggregate exception + +For saved connection base addresses containing `googleapis.com` (case-insensitive), an enabled pagination test explicitly reporting `SKIPPED` is accepted as `PASSED` in `paginationModelResults`. User pagination uses this aggregate for overall readiness; group pagination uses it for the optional-success aggregate. The individual test results remain `SKIPPED`, and the aggregate includes `googleSkipExceptionReason`. + +This applies only when `EnabledTests` includes the respective pagination test (`All`, `SCIMTests`, or its individual selector). Customer-excluded tests, failed or missing results, and non-Google skips are not promoted. Other mandatory gates and child workflow failures still block overall success. Modified Google update/null checks continue to use their applicable validations, not a blanket skip exemption. Developer portal acceptance of these exceptions is not established by the local workflow. + `FederatedIdentity` is the selector name for Workload Identity Federation (WIF), matching the existing test/output naming. Selection does not reroute the other tests' credentials: direct semantic requests still use `scimBearerToken`; provisioning requests use the separately saved Entra connection. Tokens acquired inside either authentication test remain local to that test. ### Optional Tests (WARNING, not FAIL) diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json index fac16395..5893f1b0 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json @@ -2970,7 +2970,7 @@ "type": "Wait", "inputs": { "interval": { - "count": 30, + "count": 90, "unit": "Second" } } diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json index 06c3d92c..b24a7828 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/UserTests_Workflow.json @@ -615,6 +615,17 @@ "overallResult": "@if(equals(variables('UpdateUserScopeExecutionStatus'), 'Succeeded'), if(and(and(greater(length(coalesce(body('Update_Verify_Create_Provisioning_Logs')?['value'], json('[]'))), 0), equals(toLower(coalesce(first(body('Update_Verify_Create_Provisioning_Logs')?['value'])?['provisioningStatusInfo']?['status'], '')), 'success')), greater(length(coalesce(body('Update_User_Verify_User_Created')?['Resources'], json('[]'))), 0)), 'PASSED', 'FAILED'), 'FAILED')" } }, + "Compose_Google_Empty_Update_Exception": { + "runAfter": { + "Set_UpdateUser_CreatePhase_Output_Variables": [ + "Succeeded", + "Failed", + "Skipped" + ] + }, + "type": "Compose", + "inputs": "@and(equals(actions('Set_UpdateUser_CreatePhase_Output_Variables')?['status'], 'Succeeded'), equals(variables('UpdateUserTestOutputs')?['overallResult'], 'PASSED'), contains(toLower(coalesce(triggerBody()?['initializationData']?['baseAddress'], '')), 'googleapis.com'), equals(triggerBody()?['initializationData']?['googleUpdateBodyEmptiedByFiltering'], true), equals(triggerBody()?['initializationData']?['updateUserBody'], json('{}')), not(empty(coalesce(triggerBody()?['initializationData']?['googleSkippedUpdateSources'], json('[]')))))" + }, "Update_User_Check_Create_Phase_Status": { "actions": { "Update_User_Verify_Update_And_Provisioning": { @@ -1051,10 +1062,8 @@ } }, "runAfter": { - "Set_UpdateUser_CreatePhase_Output_Variables": [ - "Succeeded", - "Failed", - "Skipped" + "Compose_Google_Empty_Update_Exception": [ + "Succeeded" ] }, "else": { @@ -1067,14 +1076,51 @@ "@toLower(variables('UpdateUserTestOutputs')['overallResult'])", "passed" ] + }, + { + "equals": [ + "@outputs('Compose_Google_Empty_Update_Exception')", + false + ] } ] }, "type": "If" }, - "Update_Cleanup_Delete_User": { + "Apply_Google_Empty_Update_Exception": { "runAfter": { "Update_User_Check_Create_Phase_Status": [ + "Succeeded", + "Skipped" + ] + }, + "type": "If", + "expression": "@equals(outputs('Compose_Google_Empty_Update_Exception'), true)", + "actions": { + "Set_Google_Empty_Update_Outputs": { + "type": "SetVariable", + "inputs": { + "name": "UpdateUserTestOutputs", + "value": { + "result": "success - Google exception: required-attribute filtering removed all available updates. Create/provisioning checks passed; no update PATCH or update verification was executed.", + "overallResult": "PASSED", + "provisioningLogsResult": "success", + "scimCheckResult": "success", + "errorDetails": { + "provisioningLogs": null, + "scimCheck": null + } + } + } + } + }, + "else": { + "actions": {} + } + }, + "Update_Cleanup_Delete_User": { + "runAfter": { + "Apply_Google_Empty_Update_Exception": [ "Succeeded", "Failed", "TimedOut", diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION index fe6b09a7..0d68f8a0 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION @@ -1 +1 @@ -15.0 +16.0 diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md index 18c49655..98357cb1 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md @@ -39,7 +39,7 @@ Collect the ISV's SCIM endpoint and bearer token, validate their Azure environme 0. **Choose one authentication method for this run** — use `ask_user` to ask: "Which authentication method do you want to validate: OAuth client credentials or Federated identity (Workload Identity Federation)?" - Record `selectedAuthenticationMethod` as `OAuth2ClientCredentials` or `FederatedIdentity`. Confirm whether to reuse supplied connection parameters or collect fresh ones. If inputs are missing or both methods were supplied, resolve the choice before continuing. Only the selected authentication test runs; its success is required alongside the existing semantic tests. Do not treat static bearer access as a third validation method. This choice does not replace `scimBearerToken` in direct semantic tests or automatically reconfigure the Entra provisioning connection. + Record `selectedAuthenticationMethod` as `OAuth2ClientCredentials` or `FederatedIdentity`. Confirm whether to reuse supplied connection parameters or collect fresh ones. If inputs are missing or both methods were supplied, resolve the choice before continuing. Only the selected authentication test runs; its success is required alongside the existing semantic tests, except for the explicit Google federation aggregate exception described below. Do not treat static bearer access as a third validation method. This choice does not replace `scimBearerToken` in direct semantic tests or automatically reconfigure the Entra provisioning connection. 1. **Tenant whitelisting (prerequisite)** — Before proceeding with SCIM validation, the ISV must submit a tenant whitelisting request by completing the form at: @@ -395,7 +395,7 @@ For federated identity testing, also pass these Phase 4 parameters: | `Federated_Identity_Test` | **SKIPPED** (not selected) | **RUNS** using the confirmed federated inputs | | Authentication result | Requires OAuth `PASSED` | Requires Federated identity `PASSED` | -Keep provisioning setup and direct-test bearer configuration distinct from this test selection. In final results, identify the selected method; an unselected `SKIPPED` is expected, but a skipped or missing selected result blocks success. +Keep provisioning setup and direct-test bearer configuration distinct from this test selection. In final results, identify the selected method; an unselected `SKIPPED` is expected. A skipped selected result blocks success unless covered by the verified Google federation aggregate exception below; a missing selected result still blocks submission. #### Step 2c: Create resource group (if needed) ```bash @@ -1252,7 +1252,7 @@ The sibling `Evaluate_Test_Results` action on the same run also exposes three to |---|---|---| | `overallLogicAppResult` | `"Succeeded"` \| `"Failed"` | LA pass/fail verdict. `"Failed"` when any Strict test (User/SCIM/Group) is not `PASSED` — including illegitimate SKIP. Group Strict tests are legitimately excused when `isGroupSupported=false`; in the ISV summary, explain that `/Schemas` does not advertise the SCIM Group resource and the Entra Group mapping is disabled when those facts were verified. Manager tests are legitimately excused when `isManagerAttributeSupported=false`. | | `optionalSuccessTestsResult` | `"PASSED"` \| `"WARNING"` | Signals WARNING when any of Delete_User, Delete_Group, Restore_Group, or SCIM_Group_Pagination is not `PASSED` (including illegitimate SKIP). Never causes `overallLogicAppResult="Failed"`. | -| `authenticationModelResult` | Selected test's result, or `"FAILED"` if missing/invalid | Only the test chosen by `selectedAuthenticationMethod` contributes. Exactly `"PASSED"` satisfies readiness; failed, skipped, missing or unexpected results block success. The other method cannot rescue it. | +| `authenticationModelResult` | Selected test's result, or `"PASSED"` by the explicit Google federation exception; otherwise `"FAILED"` if missing/invalid | Exactly `"PASSED"` satisfies authentication readiness. Outside the verified Google federation exception, failed, skipped, missing or unexpected selected results block success. The other method cannot rescue it. For Google, verify the bypass flag and reason and disclose the skipped diagnostic. | ### Step 6b: Parse each test result @@ -1278,11 +1278,11 @@ Test result semantics: - **`"success"`** — the test ran and passed. - **`"FAILED - "`** — a Strict test ran and failed. Contributes to `overallLogicAppResult="Failed"`. - **`"WARNING - "`** — an OptionalSuccess test ran and failed. Contributes to `optionalSuccessTestsResult="WARNING"` only — does not affect `overallLogicAppResult`. -- **`"SKIPPED"`** — the test was excluded by the `EnabledTests` parameter. Under the mandated `EnabledTests: "All"` this should not appear for Strict tests; if it does, the Strict test is illegitimately skipped and `overallLogicAppResult` will be `"Failed"`. +- **`"SKIPPED"`** — the test was excluded by `EnabledTests` or by an explicit workflow exception. Under the mandated `EnabledTests: "All"`, accept a Strict test's skip only for a verified Google authentication or pagination exception described below; other skipped mandatory tests block overall success. - **`"SKIPPED - Group object mapping disabled in Entra sync schema"`** — legitimate skip when `isGroupSupported=false`. Before describing why, verify the Phase 1 `/Schemas` evidence and the current Entra mapping state. When `/Schemas` does not advertise `urn:ietf:params:scim:schemas:core:2.0:Group` and the Entra Group object mapping is disabled, tell the ISV exactly that. Overall run stays `"Succeeded"`. - **`"SKIPPED - IsManagerAttributeSupported is false"`** — legitimate skip when `isManagerAttributeSupported=false`. Overall run stays `"Succeeded"`. -**Selected authentication exception:** The existing `Mandatory/StrictOneSuccess` labels are retained for compatibility. Require the selected test to return `PASSED`; the unselected test is expected to be `SKIPPED` and is not included in `Tests failed`. Do not accept success of the other method as a substitute. Any selected result other than `PASSED` blocks submission. +**Selected authentication exception:** The existing `Mandatory/StrictOneSuccess` labels are retained for compatibility. Normally require the selected test to return `PASSED`; the unselected test is expected to be `SKIPPED` and is not included in `Tests failed`. For Google + `FederatedIdentity`, accept the enabled selected diagnostic's explicit `SKIPPED` result only with `authenticationModelResult: PASSED`, `authenticationModelBypassed: true`, and a nonempty bypass reason from the workflow. Verify the Google base address in initialization data. Describe this as an aggregate exception, not a successful token exchange. Do not accept success of the other method, arbitrary skipped tests, or failed/missing selected diagnostics as a substitute. Outside this exception, any selected result other than `PASSED` blocks submission. Enabled Google pagination tests may remain individually `SKIPPED` when `paginationModelResults` explicitly accepts them as `PASSED`; report that exception separately. ### Step 6c: Drill into child workflow actions to find the real error (BEFORE pattern matching) @@ -1497,9 +1497,9 @@ Give the ISV a complete, copy-ready submission summary for the exact successful 1. **Apply the success gate.** Continue only when ALL of the following are true: - The exact captured orchestrator `` reached terminal status `Succeeded`. - `Evaluate_Test_Results.overallLogicAppResult == "Succeeded"` (fetched from the `Evaluate_Test_Results` action inputs on that run). - - `Evaluate_Test_Results.authenticationModelResult == "PASSED"` (the selected authentication test succeeded — this is a hard requirement). + - `Evaluate_Test_Results.authenticationModelResult == "PASSED"` (selected diagnostic success or the explicit Google federation exception). - Every **non-authentication** entry in `Final_TestResults.testResults[]` has `testResult` starting with `"success"`, `"SKIPPED"`, or `"WARNING"` — NEVER `"FAILED"`. - - Require the test chosen by `selectedAuthenticationMethod` to pass. Report that choice and its result. The unselected authentication test is expected to be `SKIPPED` and does not count in `Tests failed`; a failed, skipped, or missing selected result blocks submission. + - Require the test chosen by `selectedAuthenticationMethod` to report `PASSED`, except for the explicit Google federation exception above. For that exception, verify `EnabledTests` includes federation, the Google base address, the selected diagnostic's `SKIPPED` result, and the workflow's passing aggregate, bypass flag, and nonempty reason. Disclose that no standalone token exchange occurred. The unselected authentication test is expected to be `SKIPPED` and does not count in `Tests failed`; a failed/missing selected result or a selected skip outside this exception blocks submission. Report Google authentication and pagination aggregate exceptions without counting them as executed passing diagnostics. - `optionalSuccessTestsResult == "PASSED"` is preferred; `"WARNING"` is acceptable — call out each warning test explicitly in the summary. - No Phase 6 issue remains unresolved. diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md index 48a84492..868829f4 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md @@ -3,7 +3,8 @@ ## Purpose and scope [minimal-auth-selection.test.cjs](minimal-auth-selection.test.cjs) checks the -single-authentication-selection behavior in the actual workflow JSON files. +single-authentication-selection behavior and Google exception reporting in the +actual workflow JSON files. It is developer verification, not a deployed workflow or a prerequisite for an ISV's cloud validation run. @@ -19,8 +20,9 @@ federation exchange, or full gallery readiness. - Git available on `PATH`. - PowerShell 7 (`pwsh`) available on `PATH` for the offline deployment tests. This is a test/deployment tool, not a dependency of the deployed workflow. -- A repository checkout or worktree with the pre-feature baseline commit - `8bdf02e700b8c4e667c6eee6924010274a58af8f` available in its local history. +- A repository checkout or worktree with the merged Google baseline commit + `fde52cc606880c703e01d14ab7d36323a872b2e7` and the earlier + `8bdf02e700b8c4e667c6eee6924010274a58af8f` collector baseline available in its local history. A downloaded source ZIP or shallow clone missing that commit is insufficient for the preservation checks. @@ -43,32 +45,38 @@ node --test .\minimal-auth-selection.test.cjs ``` Paths to the workflow files are resolved relative to the test file, not the -terminal's current directory. The two commands run the same six tests. +terminal's current directory. The two commands run the same twelve tests. Expected summary: ```text -tests 6 -pass 6 +tests 12 +pass 12 fail 0 ``` Exact formatting and timing depend on the Node.js version and test reporter. Exit code `0` means the tests passed; a nonzero exit code indicates a failure. -## What the six tests check +## What the tests check | Test | Coverage | |---|---| | Selected branch and `EnabledTests` | OAuth and federation choices, blank/invalid selection, and existing suite/individual selectors | | Selected-result aggregation | A failed, skipped or missing selected result cannot be rescued by a passing unselected method | | Parent readiness | Only selected `PASSED` satisfies authentication readiness; an independent User failure still fails the parent; other semantic predicates match the baseline | -| Preservation and consistency | Existing nested protocol actions, other suite definitions and collector remain unchanged; documentation uses the selector name; the template version is blank; final results identify the choice | +| Google WIF reporting | Individual diagnostic results stay unchanged, including `SKIPPED` for Google federation. The existing Google + `FederatedIdentity` exception passes only the authentication aggregate and retains its bypass reason; other providers still require the selected diagnostic result. No individual fields are added. | +| Google pagination eligibility | Only enabled, explicitly skipped Google pagination receives aggregate `PASSED`; missing/failed results and non-Google or customer-excluded skips are not promoted. Individual results remain unchanged. | +| Google pagination readiness | Aggregate results reach overall readiness and final metadata; group failures remain optional warnings, and independent semantic/runtime failures still block success. | +| Google empty-update provenance | Only a previously nonempty Google payload emptied by filtering receives the exception marker; already-empty/null payloads and other providers do not. | +| Google nested-update filtering | Removing the last required blank/null nested source prunes its empty parent and reaches the existing pass exception; populated siblings, `false`, `0`, missing sources, and non-Google behavior are preserved. | +| Google empty-update reporting | Successful create/provisioning remains mandatory; only eligible empty updates bypass the update phase and report `PASSED` with an explicit explanation. Normal updates and cleanup dependencies are preserved. | +| Preservation and consistency | Existing nested protocol actions, unrelated suite logic and collector remain unchanged, except the intentional direct-user-update propagation wait adjustment; documentation uses the selector name; the template version is blank; final results identify the choice | | Version and timing output | Native result uses the deployed version; timestamps come from single captures; epoch/duration calculations cover whole seconds, subsecond truncation and midnight crossing | | Offline deployment | The PowerShell test below verifies version injection, preserved parameters, read-back and preflight failures without cloud calls | [version-deployment.test.ps1](version-deployment.test.ps1) is invoked by the -sixth Node test. It parses the deployment script and loads only the tested +offline deployment Node test. It parses the deployment script and loads only the tested functions, mocking Azure CLI and HTTP requests. It also invokes guarded preflight failure paths that exit before any Azure operation. To run it separately from this directory: diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs index af46c664..4117c7cd 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs @@ -9,18 +9,24 @@ const {test} = require('node:test'); const fs = require('node:fs'); const path = require('node:path'); const {execFileSync} = require('node:child_process'); +const {isDeepStrictEqual} = require('node:util'); const root = path.resolve(__dirname, '..'); const load = name => JSON.parse(fs.readFileSync(path.join(root, name), 'utf8')); -// Pin the pre-feature master revision so preservation checks do not move with master. -const baseline = name => JSON.parse(execFileSync('git', ['show', `8bdf02e:Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/${name}`], {cwd: root, encoding: 'utf8', maxBuffer: 20e6})); +// Pin the merged Google fixes so preservation checks do not move with master. +const baseline = name => JSON.parse(execFileSync('git', ['show', `fde52cc:Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/${name}`], {cwd: root, encoding: 'utf8', maxBuffer: 20e6})); const child = load('SCIMTests_Workflow.json').definition.actions; const parent = load('Orchestrator_Workflow.json').definition.actions; +const userUpdateActions = workflow => workflow.definition.actions.UserTests_Scope.actions + .Update_User_Test.actions['Update_User_Test_-_Actions'].actions; +const googleRequiredUpdateActions = workflow => workflow.definition.actions.Protect_Google_Required_User_Attributes + .actions.ForEach_Google_User_Target.actions.Check_Google_Target_Required.actions; // Evaluate only the expression subset used below, with simulated action outputs. // This is not a general interpreter: it does not model runtime scheduling or lazy branches. function evaluate(expression, context) { if (typeof expression === 'object') { const [op, args] = Object.entries(expression)[0]; + if (op === 'not') return !evaluate(args, context); const values = args.map(value => evaluate(value, context)); return op === 'and' ? values.every(Boolean) : op === 'or' ? values.some(Boolean) : values[0] === values[1]; } @@ -31,11 +37,22 @@ function evaluate(expression, context) { parameters: name => context.parameters?.[name], variables: name => context.variables?.[name], triggerBody: () => context.triggerBody, body: name => context.bodies?.[name], actions: name => context.actions?.[name], outputs: name => context.outputs?.[name], - equals: (a, b) => a === b, not: value => !value, and: (...args) => args.every(Boolean), + items: name => context.items?.[name], + equals: isDeepStrictEqual, not: value => !value, and: (...args) => args.every(Boolean), or: (...args) => args.some(Boolean), if: (condition, yes, no) => condition ? yes : no, coalesce: (...args) => args.find(value => value != null), json: JSON.parse, string: value => typeof value === 'object' ? JSON.stringify(value) : String(value), - contains: (value, item) => value?.includes(item) || false, + contains: (value, item) => value !== null && typeof value === 'object' && !Array.isArray(value) + ? Object.hasOwn(value, item) : value?.includes(item) || false, + toLower: value => value.toLowerCase(), + replace: (value, search, replacement) => value.replaceAll(search, replacement), + first: value => value?.[0], + last: value => value?.at(-1), + split: (value, delimiter) => value.split(delimiter), + setProperty: (value, key, item) => ({...value, [key]: item}), + removeProperty: (value, key) => Object.fromEntries(Object.entries(value).filter(([name]) => name !== key)), + empty: value => value == null || value === '' || + (typeof value === 'object' && Object.keys(value).length === 0), ticks: value => { const fraction = /\.(\d+)Z$/.exec(value)?.[1] || ''; return BigInt(Date.parse(value.replace(/\.\d+Z$/, 'Z'))) * 10000n + @@ -87,10 +104,23 @@ function evaluate(expression, context) { return result; } const methods = ['OAuth2ClientCredentials', 'FederatedIdentity']; +function authenticationContext(context) { + context.outputs ??= {}; + context.outputs.Compose_Google_Federated_Auth_Bypass = evaluate(child.Compose_Google_Federated_Auth_Bypass.inputs, context); + context.outputs.Compose_Authentication_Model_Result = evaluate(child.Compose_Authentication_Model_Result.inputs, context); + return context; +} +function paginationContext(context) { + context.outputs ??= {}; + context.outputs.Compose_Pagination_Model_Results = Object.fromEntries( + Object.entries(parent.Compose_Pagination_Model_Results.inputs) + .map(([key, expression]) => [key, evaluate(expression, context)])); + return context; +} test('only the selected existing branch runs; EnabledTests remains honored', () => { for (const method of [...methods, '', 'invalid']) { for (const enabled of ['All', 'SCIMTests', 'UserTests', 'Validate_Credentials_Test', 'Federated_Identity_Test']) { - const context = {parameters: {selectedAuthenticationMethod: method}, triggerBody: {EnabledTests: enabled}}; + const context = authenticationContext({parameters: {selectedAuthenticationMethod: method}, triggerBody: {EnabledTests: enabled}}); for (const [index, name] of ['Validate_Credentials_Test', 'Federated_Identity_Test'].entries()) { assert.equal(evaluate(child.SCIMTests_Scope.actions[name].expression, context), method === methods[index] && ['All', 'SCIMTests', name].includes(enabled)); @@ -104,18 +134,20 @@ test('selected missing, skipped or failed output cannot be rescued by the other // Keep the unselected result PASSED to detect an accidental return to OR logic. const variables = {ValidateCredentialsTestOutputs: {overallResult: 'PASSED'}, FederatedIdentityTestOutputs: {overallResult: 'PASSED'}}; variables[index ? 'FederatedIdentityTestOutputs' : 'ValidateCredentialsTestOutputs'] = {overallResult: outcome}; - const actual = evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, {parameters: {selectedAuthenticationMethod: method}, variables}); + const actual = evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, + authenticationContext({parameters: {selectedAuthenticationMethod: method}, variables})); assert.equal(actual === 'PASSED', outcome === 'PASSED'); } } - assert.equal(evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, {parameters: {selectedAuthenticationMethod: ''}}), 'FAILED'); + assert.equal(evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, + authenticationContext({parameters: {selectedAuthenticationMethod: ''}})), 'FAILED'); }); test('parent requires selected PASSED while preserving independent semantic failures', () => { const original = baseline('Orchestrator_Workflow.json').definition.actions.Evaluate_Test_Results.inputs.overallLogicAppResult; const current = parent.Evaluate_Test_Results.inputs.overallLogicAppResult; - // Reversing only the authentication predicate must recover the original semantic gates. - assert.equal(current.replace("not(equals(coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'],'FAILED'),'PASSED'))", - "equals(coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'],'FAILED'),'FAILED')"), original); + // Only the user-pagination gate changes from the merged Google baseline. + assert.equal(current.replace("coalesce(outputs('Compose_Pagination_Model_Results')?['userPaginationResult'],'FAILED')", + "coalesce(body('Get_SCIMTests_Output_Content')?['SCIMUserPaginationTestOutputs']?['overallResult'],'SKIPPED')"), original); const outputs = {}; for (const file of ['UserTests_Workflow.json', 'GroupTests_Workflow.json', 'SCIMTests_Workflow.json']) { const final = load(file).definition.actions.Compose_Final_Results.inputs; @@ -125,21 +157,281 @@ test('parent requires selected PASSED while preserving independent semantic fail const context = {bodies: {...outputs, Call_Initialization_Workflow: {isGroupSupported: true}, Poll_SCIMTests_Status: {properties: {status: 'Succeeded'}}}}; for (const result of ['PASSED', 'FAILED', 'SKIPPED', undefined]) { context.bodies.Get_SCIMTests_Output_Content.authenticationModelResult = result; - assert.equal(evaluate(current, context), result === 'PASSED' ? 'Succeeded' : 'Failed'); + assert.equal(evaluate(current, paginationContext(context)), result === 'PASSED' ? 'Succeeded' : 'Failed'); } context.bodies.Get_SCIMTests_Output_Content.authenticationModelResult = 'PASSED'; context.bodies.Get_UserTests_Output_Content.CreateUserTestOutputs.overallResult = 'FAILED'; - assert.equal(evaluate(current, context), 'Failed'); + assert.equal(evaluate(current, paginationContext(context)), 'Failed'); +}); +test('Google WIF keeps individual results unchanged and accepts its exception only in the aggregate', () => { + const reportedResult = child.Compose_Final_Results.inputs.FederatedIdentityTestOutputs; + const parentResult = parent.Final_TestResults.inputs.testResults.find(entry => entry.testName === 'Federated_Identity_Test'); + for (const baseAddress of ['https://example.GOOGLEAPIS.COM/scim', 'https://scim.example.test', undefined]) { + for (const method of [...methods, 'invalid']) { + for (const enabled of ['All', 'SCIMTests', 'Federated_Identity_Test', 'UserTests', undefined]) { + for (const result of ['SKIPPED', 'PASSED', 'FAILED', undefined]) { + const raw = {overallResult: result, result: result ?? 'missing', summary: {original: true}}; + const context = authenticationContext({ + parameters: {selectedAuthenticationMethod: method}, + triggerBody: {EnabledTests: enabled, initializationData: {baseAddress}}, + variables: {FederatedIdentityTestOutputs: raw}, + }); + const reported = evaluate(reportedResult, context); + assert.deepEqual(reported, raw); + assert.equal(evaluate(parentResult.testResult, {bodies: { + Get_SCIMTests_Output_Content: {FederatedIdentityTestOutputs: reported}, + }}), reported.result); + const exempted = baseAddress?.includes('GOOGLEAPIS.COM') && method === 'FederatedIdentity'; + assert.equal(evaluate(child.Compose_Final_Results.inputs.authenticationModelBypassed, context), Boolean(exempted)); + if (exempted && result === 'SKIPPED') { + assert.equal(evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, context), 'PASSED'); + assert.equal(evaluate(child.SCIMTests_Scope.actions.Federated_Identity_Test.expression, context), false); + assert.match(evaluate(child.Compose_Final_Results.inputs.authenticationModelBypassReason, context), + /standalone authentication diagnostics skipped/); + } else if (!exempted && method === 'FederatedIdentity') { + assert.equal(evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, context), result ?? 'FAILED'); + } + assert.equal(raw.overallResult, result); + } + } + } + } +}); +test('Google pagination aggregate accepts only enabled explicit skips and preserves individual results', () => { + for (const baseAddress of ['https://example.GOOGLEAPIS.COM/scim', 'https://scim.example.test', '', undefined]) { + for (const enabled of ['All', 'SCIMTests', 'UserTests', 'SCIM_User_Pagination_Test', 'SCIM_Group_Pagination_Test', undefined]) { + for (const result of ['PASSED', 'SKIPPED', 'FAILED', undefined]) { + const context = { + parameters: {EnabledTests: enabled}, + bodies: { + Call_Initialization_Workflow: {baseAddress}, + Get_SCIMTests_Output_Content: { + SCIMUserPaginationTestOutputs: {overallResult: result}, + SCIMGroupPaginationTestOutputs: {overallResult: result}, + }, + }, + }; + const original = structuredClone(context.bodies); + const aggregate = paginationContext(context).outputs.Compose_Pagination_Model_Results; + for (const [key, selector] of [['userPaginationResult', 'SCIM_User_Pagination_Test'], + ['groupPaginationResult', 'SCIM_Group_Pagination_Test']]) { + const exempted = baseAddress?.includes('GOOGLEAPIS.COM') && + ['All', 'SCIMTests', selector].includes(enabled) && result === 'SKIPPED'; + assert.equal(aggregate[key], exempted ? 'PASSED' : result ?? 'FAILED', + `${baseAddress}, ${enabled}, ${key}, ${result}`); + } + assert.deepEqual(context.bodies, original); + assert.equal(aggregate.googleSkipExceptionReason !== null, Boolean(baseAddress?.includes('GOOGLEAPIS.COM'))); + } + } + } +}); +test('Google pagination aggregate reaches readiness and final results without hiding other failures', () => { + const bodies = {Call_Initialization_Workflow: {baseAddress: 'https://example.googleapis.com/scim', + isGroupSupported: true, isManagerAttributeSupported: true, targetDirectoryUserAttributes: [{name: 'manager'}]}}; + for (const file of ['UserTests_Workflow.json', 'GroupTests_Workflow.json', 'SCIMTests_Workflow.json']) { + const final = load(file).definition.actions.Compose_Final_Results.inputs; + bodies[`Get_${file.replace('_Workflow.json', '')}_Output_Content`] = + Object.fromEntries(Object.keys(final).filter(key => key.endsWith('Outputs')).map(key => [key, {overallResult: 'PASSED'}])); + } + Object.assign(bodies.Get_SCIMTests_Output_Content, {authenticationModelResult: 'PASSED', + FederatedIdentityTestOutputs: {overallResult: 'SKIPPED', result: 'SKIPPED'}, + SCIMUserPaginationTestOutputs: {overallResult: 'SKIPPED', result: 'SKIPPED'}, + SCIMGroupPaginationTestOutputs: {overallResult: 'SKIPPED', result: 'SKIPPED'}}); + const context = paginationContext({parameters: {EnabledTests: 'All'}, bodies}); + const overall = parent.Evaluate_Test_Results.inputs.overallLogicAppResult; + const optional = parent.Evaluate_Test_Results.inputs.optionalSuccessTestsResult; + assert.equal(evaluate(overall, context), 'Succeeded'); + assert.equal(evaluate(optional, context), 'PASSED'); + const federation = parent.Final_TestResults.inputs.testResults.find(entry => entry.testName === 'Federated_Identity_Test'); + assert.equal(evaluate(federation.testResult, context), 'SKIPPED'); + context.outputs.Evaluate_Test_Results = {paginationModelResults: + evaluate(parent.Evaluate_Test_Results.inputs.paginationModelResults, context)}; + assert.deepEqual(evaluate(parent.Final_TestResults.inputs.paginationModelResults, context), + context.outputs.Compose_Pagination_Model_Results); + for (const name of ['SCIM_User_Pagination_Test', 'SCIM_Group_Pagination_Test']) { + const result = parent.Final_TestResults.inputs.testResults.find(entry => entry.testName === name); + assert.equal(evaluate(result.testResult, context), 'SKIPPED'); + } + bodies.Get_UserTests_Output_Content.CreateUserTestOutputs.overallResult = 'FAILED'; + assert.equal(evaluate(overall, context), 'Failed'); + bodies.Get_UserTests_Output_Content.CreateUserTestOutputs.overallResult = 'PASSED'; + bodies.Poll_SCIMTests_Status = {properties: {status: 'Failed'}}; + assert.equal(evaluate(overall, context), 'Failed'); + delete bodies.Poll_SCIMTests_Status; + for (const result of ['FAILED', undefined]) { + bodies.Get_SCIMTests_Output_Content.SCIMUserPaginationTestOutputs = {overallResult: result}; + assert.equal(evaluate(overall, paginationContext(context)), 'Failed'); + } + bodies.Get_SCIMTests_Output_Content.SCIMUserPaginationTestOutputs = {overallResult: 'SKIPPED'}; + bodies.Get_SCIMTests_Output_Content.SCIMGroupPaginationTestOutputs = {overallResult: 'FAILED'}; + assert.equal(evaluate(overall, paginationContext(context)), 'Succeeded'); + assert.equal(evaluate(optional, context), 'WARNING'); + context.parameters.EnabledTests = 'UserTests'; + assert.equal(evaluate(overall, paginationContext(context)), 'Failed'); + assert.ok(parent.Evaluate_Test_Results.runAfter.Compose_Pagination_Model_Results.includes('Succeeded')); + assert.ok(parent.Compose_Pagination_Model_Results.runAfter.Get_SCIMTests_Output_Content.includes('Failed')); +}); +test('Google empty-update provenance requires a nonempty original payload removed by filtering', () => { + const expression = load('Initialization_Workflow.json').definition.actions + .Response_Initialization_Data.inputs.body.googleUpdateBodyEmptiedByFiltering; + for (const [baseAddress, original, filtered, skipped, expected] of [ + ['https://example.GOOGLEAPIS.COM/scim', {otherMails: ['']}, {}, ['otherMails'], true], + ['https://example.googleapis.com/scim', {}, {}, ['otherMails'], false], + ['https://example.googleapis.com/scim', null, {}, ['otherMails'], false], + ['https://example.googleapis.com/scim', {displayName: 'Updated'}, {displayName: 'Updated'}, [], false], + ['https://example.googleapis.com/scim', {otherMails: ['']}, {}, [], false], + ['https://example.googleapis.com/scim', {otherMails: ['']}, null, ['otherMails'], false], + ['https://scim.example.test', {otherMails: ['']}, {}, ['otherMails'], false], + ]) { + assert.equal(evaluate(expression, { + bodies: {Filter_BaseAddress_Connectivity_Parameter: [{value: baseAddress}]}, + outputs: {Compose_Final_Dynamic_Update_User_Body: original}, + variables: {googleUpdateUserBody: filtered, googleSkippedUpdateSources: skipped}, + }), expected); + } +}); +test('Google filtering prunes only nested containers emptied by removing a mapped source', () => { + const initialization = load('Initialization_Workflow.json'); + const protection = initialization.definition.actions.Protect_Google_Required_User_Attributes; + const actions = googleRequiredUpdateActions(initialization); + const exception = initialization.definition.actions.Response_Initialization_Data.inputs.body.googleUpdateBodyEmptiedByFiltering; + const source = 'employeeOrgData.costCenter'; + for (const [original, nonemptyValues, expected, exempted] of [ + [{employeeOrgData: {costCenter: ''}}, [], {}, true], + [{employeeOrgData: {costCenter: null}}, [], {}, true], + [{employeeOrgData: {costCenter: '', division: 'Engineering'}}, [], + {employeeOrgData: {division: 'Engineering'}}, false], + [{employeeOrgData: {costCenter: ''}, displayName: 'Updated'}, [], {displayName: 'Updated'}, false], + [{employeeOrgData: {}}, [], {employeeOrgData: {}}, false], + [{displayName: 'Updated'}, [], {displayName: 'Updated'}, false], + [{employeeOrgData: {costCenter: '123'}}, ['123'], {employeeOrgData: {costCenter: '123'}}, false], + [{employeeOrgData: {costCenter: false}}, [false], {employeeOrgData: {costCenter: false}}, false], + [{employeeOrgData: {costCenter: 0}}, [0], {employeeOrgData: {costCenter: 0}}, false], + ]) { + const context = { + items: {ForEach_Google_User_Target: {source}}, + variables: {googleUpdateUserBody: original, googleSkippedUpdateSources: nonemptyValues.length ? [] : [source]}, + bodies: { + Filter_Google_Nonempty_Update_Values: nonemptyValues, + Filter_BaseAddress_Connectivity_Parameter: [{value: 'https://example.googleapis.com/scim'}], + }, + outputs: {Compose_Dynamic_ArrayProperties: [], Compose_Final_Dynamic_Update_User_Body: original}, + }; + assert.equal(evaluate(protection.expression, context), true); + context.outputs.Compose_Google_Update_Parent_After_Removal = + evaluate(actions.Compose_Google_Update_Parent_After_Removal.inputs, context); + const filtered = evaluate(actions.Compose_Google_Protected_Update_Body.inputs, context); + assert.deepEqual(filtered, expected); + assert.deepEqual(context.variables.googleUpdateUserBody, original, 'compose must not mutate the input'); + context.variables.googleUpdateUserBody = filtered; + assert.equal(evaluate(exception, context), exempted); + const userActions = userUpdateActions(load('UserTests_Workflow.json')); + assert.equal(evaluate(userActions.Compose_Google_Empty_Update_Exception.inputs, { + triggerBody: {initializationData: { + baseAddress: 'https://example.googleapis.com/scim', updateUserBody: filtered, + googleUpdateBodyEmptiedByFiltering: evaluate(exception, context), + googleSkippedUpdateSources: context.variables.googleSkippedUpdateSources, + }}, + variables: {UpdateUserTestOutputs: {overallResult: 'PASSED'}}, + actions: {Set_UpdateUser_CreatePhase_Output_Variables: {status: 'Succeeded'}}, + }), exempted); + context.bodies.Filter_BaseAddress_Connectivity_Parameter[0].value = 'https://scim.example.test'; + assert.equal(evaluate(protection.expression, context), false, 'other providers do not run this filtering'); + assert.equal(evaluate(exception, context), false); + } + assert.deepEqual(actions.Compose_Google_Update_Parent_After_Removal.runAfter, + {Filter_Google_Nonempty_Update_Values: ['Succeeded']}); + assert.deepEqual(actions.Compose_Google_Protected_Update_Body.runAfter, + {Compose_Google_Update_Parent_After_Removal: ['Succeeded']}); +}); +test('Google filtering-only empty updates pass explicitly without executing the update phase', () => { + const actions = userUpdateActions(load('UserTests_Workflow.json')); + const context = { + triggerBody: {initializationData: { + baseAddress: 'https://example.GOOGLEAPIS.COM/scim', + updateUserBody: {}, googleUpdateBodyEmptiedByFiltering: true, + googleSkippedUpdateSources: ['otherMails'], + }}, + variables: {UpdateUserTestOutputs: {overallResult: 'PASSED'}}, + actions: {Set_UpdateUser_CreatePhase_Output_Variables: {status: 'Succeeded'}}, + outputs: {}, + }; + const check = current => { + current.outputs.Compose_Google_Empty_Update_Exception = + evaluate(actions.Compose_Google_Empty_Update_Exception.inputs, current); + return { + update: evaluate(actions.Update_User_Check_Create_Phase_Status.expression, current), + exception: evaluate(actions.Apply_Google_Empty_Update_Exception.expression, current), + }; + }; + assert.deepEqual(check(context), {update: false, exception: true}); + const result = actions.Apply_Google_Empty_Update_Exception.actions.Set_Google_Empty_Update_Outputs.inputs.value; + assert.equal(result.overallResult, 'PASSED'); + assert.match(result.result, /^success.*Google exception.*no update PATCH or update verification was executed/); + for (const [key, value] of [ + ['baseAddress', 'https://scim.example.test'], + ['googleUpdateBodyEmptiedByFiltering', false], + ['googleUpdateBodyEmptiedByFiltering', undefined], + ['googleSkippedUpdateSources', []], + ['updateUserBody', {displayName: 'Updated'}], + ['updateUserBody', null], + ]) { + const current = structuredClone(context); + current.triggerBody.initializationData[key] = value; + assert.deepEqual(check(current), {update: true, exception: false}, key); + } + for (const overallResult of ['FAILED', 'SKIPPED']) { + const current = structuredClone(context); + current.variables.UpdateUserTestOutputs.overallResult = overallResult; + assert.deepEqual(check(current), {update: false, exception: false}); + } + const failedSetup = structuredClone(context); + failedSetup.actions.Set_UpdateUser_CreatePhase_Output_Variables.status = 'Failed'; + assert.equal(check(failedSetup).exception, false); + assert.deepEqual(actions.Update_User_Check_Create_Phase_Status.runAfter, + {Compose_Google_Empty_Update_Exception: ['Succeeded']}); + assert.deepEqual(actions.Compose_Google_Empty_Update_Exception.runAfter, + {Set_UpdateUser_CreatePhase_Output_Variables: ['Succeeded', 'Failed', 'Skipped']}); + assert.deepEqual(actions.Apply_Google_Empty_Update_Exception.runAfter, + {Update_User_Check_Create_Phase_Status: ['Succeeded', 'Skipped']}); + assert.deepEqual(actions.Update_Cleanup_Delete_User.runAfter, + {Apply_Google_Empty_Update_Exception: ['Succeeded', 'Failed', 'TimedOut', 'Skipped']}); + const guard = actions.Update_User_Check_Create_Phase_Status.actions.Update_User_Verify_Update_And_Provisioning + .actions.Validate_User_Update_Attributes.actions.Validate_Google_User_Update_Attributes; + assert.equal(guard.inputs.schema.minProperties, 1, 'non-exempt empty Google updates still fail'); }); -test('token protocols and semantic source remain unchanged; naming and bundle version agree', () => { +test('token protocols and unrelated semantic source remain unchanged; naming and bundle version agree', () => { const old = baseline('SCIMTests_Workflow.json').definition.actions; for (const name of Object.keys(old.SCIMTests_Scope.actions)) { const actual = structuredClone(child.SCIMTests_Scope.actions[name]); // Permit the two new selection gates, but no changes inside their protocol actions. if (['Validate_Credentials_Test', 'Federated_Identity_Test'].includes(name)) actual.expression = old.SCIMTests_Scope.actions[name].expression; + if (name === 'SCIM_User_Update_Test') { + actual.actions.SCIM_User_Update_Test_Actions.actions.Check_Update_Has_Eligible_Attrs.actions + .Delay_SCIM_Update_User_After_Patch.inputs.interval.count = + old.SCIMTests_Scope.actions[name].actions.SCIM_User_Update_Test_Actions.actions + .Check_Update_Has_Eligible_Attrs.actions.Delay_SCIM_Update_User_After_Patch.inputs.interval.count; + } assert.deepEqual(actual, old.SCIMTests_Scope.actions[name], name); } - for (const name of ['Initialization_Workflow.json', 'UserTests_Workflow.json', 'GroupTests_Workflow.json']) assert.deepEqual(load(name), baseline(name)); + const initialization = load('Initialization_Workflow.json'); + delete initialization.definition.actions.Response_Initialization_Data.inputs.body.googleUpdateBodyEmptiedByFiltering; + const protection = googleRequiredUpdateActions(initialization); + delete protection.Compose_Google_Update_Parent_After_Removal; + protection.Compose_Google_Protected_Update_Body = + googleRequiredUpdateActions(baseline('Initialization_Workflow.json')).Compose_Google_Protected_Update_Body; + assert.deepEqual(initialization, baseline('Initialization_Workflow.json')); + const users = load('UserTests_Workflow.json'); + const updated = userUpdateActions(users); + const original = userUpdateActions(baseline('UserTests_Workflow.json')); + delete updated.Compose_Google_Empty_Update_Exception; + delete updated.Apply_Google_Empty_Update_Exception; + updated.Update_User_Check_Create_Phase_Status.expression = original.Update_User_Check_Create_Phase_Status.expression; + updated.Update_User_Check_Create_Phase_Status.runAfter = original.Update_User_Check_Create_Phase_Status.runAfter; + updated.Update_Cleanup_Delete_User.runAfter = original.Update_Cleanup_Delete_User.runAfter; + assert.deepEqual(users, baseline('UserTests_Workflow.json')); + assert.deepEqual(load('GroupTests_Workflow.json'), baseline('GroupTests_Workflow.json')); // VERSION is authoritative; future releases should not require a test-only version edit. const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); assert.notEqual(version, '', 'VERSION must contain a bundle version');