From 9aee2c172eed4c4884e028a621fc679d14c51ff0 Mon Sep 17 00:00:00 2001 From: "Prashant Rane (ALLYIS INC)" Date: Wed, 21 Oct 2026 08:49:48 -0600 Subject: [PATCH 1/2] Support single authentication method selection for SCIM onboarding Adds an explicit choice between OAuth client credentials and Federated identity for each SCIM onboarding validation run. The onboarding agent confirms the selected method and its connection parameters, and the Logic App runs only the corresponding authentication test. Requires the selected test to pass, preserves existing semantic validation requirements, and includes the selected method in final results. Direct SCIM bearer usage and separately configured Entra provisioning credentials remain unchanged. Updates shared parameters, authentication result aggregation, onboarding instructions, setup documentation, and the validation overview. Adds commented local regression tests with execution documentation and checks version consistency against VERSION rather than a hard-coded release. Bumps the Standard Logic App version to 14.0. Validation: all four local regression tests pass from both the worktree root and the tests directory. An OAuth - selected run, completed with overall success, 20 successes, no failures or warnings, and five skips: four explicitly excluded Group tests and unselected federation. And successful added test successfully exercised the FederatedIdentity-Selected case.. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Orchestrator_Parameters.json | 9 +- .../Orchestrator_Workflow.json | 5 +- .../SCIM-Validation-Test-Overview.md | 31 ++-- .../StandardLogicApp/SCIMTests_Workflow.json | 42 +++-- .../SetupLogicApp-Standard-Agent.md | 16 +- .../StandardLogicApp/VERSION | 2 +- .../StandardLogicApp/scim-onboarding.agent.md | 44 +++-- .../StandardLogicApp/tests/README.md | 115 +++++++++++++ .../tests/minimal-auth-selection.test.cjs | 151 ++++++++++++++++++ 9 files changed, 355 insertions(+), 60 deletions(-) create mode 100644 Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md create mode 100644 Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json index ed7d6bc5..f11545d6 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json @@ -1,4 +1,11 @@ { + "selectedAuthenticationMethod": { + "type": "String", + "value": "", + "metadata": { + "description": "ISV-selected authentication test for this run: OAuth2ClientCredentials or FederatedIdentity. Direct semantic tests still use scimBearerToken." + } + }, "EnabledTests": { "allowedValues": [ "All", @@ -39,7 +46,7 @@ "description": "Version of the Logic App for tracking and deployment purposes" }, "type": "String", - "value": "10.0" + "value": "14.0" }, "attributeTransforms": { "metadata": { diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json index 3d2a3c1b..970d2432 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json @@ -504,7 +504,7 @@ "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(equals(coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'],'FAILED'),'FAILED'),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'))", + "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')", "authenticationModelResult": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'], 'FAILED')" } @@ -751,7 +751,8 @@ "SCIMTests_Workflow": "@outputs('Compose_SCIMTests_RunLink')" }, "authenticationModelResult": "@outputs('Evaluate_Test_Results')?['authenticationModelResult']", - "overallResult": "@outputs('Evaluate_Test_Results')?['overallLogicAppResult']" + "overallResult": "@outputs('Evaluate_Test_Results')?['overallLogicAppResult']", + "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')" } }, "Terminate_With_Test_Status": { diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md index 3a05925a..09225abb 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md @@ -93,21 +93,22 @@ Tests that don't apply are reported as **SKIPPED** (not failures). However, grou The Orchestrator's `Evaluate_Test_Results` action computes `overallLogicAppResult` as follows: -- **`Succeeded`** — ALL mandatory tests returned `PASSED` or `SKIPPED`, AND at least one authentication test passed (see below). -- **`Failed`** — ANY mandatory test returned `FAILED`, OR both authentication tests failed. +- **`Succeeded`** — Existing mandatory/capability gates pass, AND the selected authentication test returned `PASSED`. +- **`Failed`** — A required semantic gate does not pass, OR the selected authentication test is failed, skipped, missing, or the selection is invalid. -### Authentication Model (OR logic) +### Authentication Model (one selected method) -The authentication result uses **OR logic** across two tests: +Set `selectedAuthenticationMethod` to one of the following values. Existing token-exchange actions are reused; the unselected test stays `SKIPPED` and makes no token request. -| Validate_Credentials_Test | Federated_Identity_Test | Auth Model Result | -|---------------------------|------------------------|-------------------| -| PASSED | PASSED | **PASSED** | -| PASSED | FAILED/SKIPPED | **PASSED** | -| FAILED/SKIPPED | PASSED | **PASSED** | -| FAILED/SKIPPED | FAILED/SKIPPED | **FAILED** — blocks overall `Succeeded` | +| Selection | Required test | Authentication result | +|-----------|---------------|-----------------------| +| `OAuth2ClientCredentials` | `Validate_Credentials_Test` | PASSED only when this test passes | +| `FederatedIdentity` | `Federated_Identity_Test` | PASSED only when this test passes | +| Empty/invalid | Neither | FAILED | -This means an ISV only needs to pass **one** of the two authentication tests. If they configured OAuth only, a `Federated_Identity_Test` failure does not block submission. +Final results identify the selection. Success of the other method cannot rescue a failed or skipped selected test. Existing non-authentication requirements remain unchanged. + +`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) @@ -126,8 +127,8 @@ These 4 tests are `testCategory: optional`. When they fail, the `testResult` fie |------|---------------|------------| | User_Update_Manager_Test | `isManagerAttributeSupported = false` | SKIPPED (pass) | | SCIM_Update_Manager_Test | `manager` not in target directory attributes | SKIPPED (pass) | -| Validate_Credentials_Test | `scimTokenEndpoint` is empty (static bearer token) | SKIPPED — ok if Federated_Identity passes | -| Federated_Identity_Test | Federated parameters are empty | SKIPPED — ok if Validate_Credentials passes | +| Validate_Credentials_Test | Federated identity selected | Expected SKIPPED; selected federation must pass | +| Federated_Identity_Test | OAuth selected | Expected SKIPPED; selected OAuth must pass | ### Group Tests Are Mandatory @@ -143,9 +144,9 @@ This applies to: Create_Group, Update_Group, Group_Update_Add_Member, Group_Upda |----------|---------------|--------| | All 25 tests: `success` | **Succeeded** | Ready for gallery submission | | All mandatory pass, optional tests show `WARNING` | **Succeeded** | Ready — warnings are informational | -| OAuth configured + Validate_Credentials passes, Federated_Identity fails (no federated params) | **Succeeded** | Ready — only one auth test needed | +| OAuth selected + Validate_Credentials passes, Federated_Identity skipped | **Succeeded** | Ready if other required tests pass | | Federated configured + Federated_Identity passes, Validate_Credentials skipped | **Succeeded** | Ready — only one auth test needed | -| Both Validate_Credentials AND Federated_Identity fail/skip | **Failed** | Must fix — at least one auth test must pass | +| Selected authentication test fails/skips or its result is missing | **Failed** | Must fix the selected method | | Manager tests `SKIPPED` (no manager mapping) | **Succeeded** | Ready — ISV should document no manager support | | Disable_User_Test `FAILED` | **Failed** | Must implement soft-delete (`active: false`) on SCIM endpoint | | Group tests `FAILED` (no Group schema) | **Failed** | Must add Group support to SCIM endpoint | diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json index 690ec0a4..39b3b5c7 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json @@ -3630,23 +3630,18 @@ "actions": {} }, "expression": { - "or": [ - { - "equals": [ - "@triggerBody()?['EnabledTests']", - "All" - ] - }, + "and": [ { "equals": [ - "@triggerBody()?['EnabledTests']", - "SCIMTests" + "@parameters('selectedAuthenticationMethod')", + "OAuth2ClientCredentials" ] }, { - "equals": [ - "@triggerBody()?['EnabledTests']", - "Validate_Credentials_Test" + "or": [ + { "equals": [ "@triggerBody()?['EnabledTests']", "All" ] }, + { "equals": [ "@triggerBody()?['EnabledTests']", "SCIMTests" ] }, + { "equals": [ "@triggerBody()?['EnabledTests']", "Validate_Credentials_Test" ] } ] } ] @@ -4152,17 +4147,18 @@ "actions": {} }, "expression": { - "or": [ - { - "equals": [ "@triggerBody()?['EnabledTests']", "All" ] - }, + "and": [ { - "equals": [ "@triggerBody()?['EnabledTests']", "SCIMTests" ] + "equals": [ + "@parameters('selectedAuthenticationMethod')", + "FederatedIdentity" + ] }, { - "equals": [ - "@triggerBody()?['EnabledTests']", - "Federated_Identity_Test" + "or": [ + { "equals": [ "@triggerBody()?['EnabledTests']", "All" ] }, + { "equals": [ "@triggerBody()?['EnabledTests']", "SCIMTests" ] }, + { "equals": [ "@triggerBody()?['EnabledTests']", "Federated_Identity_Test" ] } ] } ] @@ -5302,8 +5298,10 @@ "SCIMGroupPaginationTestOutputs": "@variables('SCIMGroupPaginationTestOutputs')", "SCIMUpdateManagerTestOutputs": "@variables('SCIMUpdateManagerTestOutputs')", "testSuiteName": "SCIMTests", - "status": "@if(or(or(or(equals(variables('SchemaDiscoverabilityTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMNullUpdateTestOutputs')['overallResult'], 'FAILED')), or(or(equals(variables('ValidateCredentialsTestOutputs')['overallResult'], 'FAILED'), equals(variables('FederatedIdentityTestOutputs')['overallResult'], 'FAILED')), equals(variables('SCIMUserCreateTestOutputs')['overallResult'], 'FAILED'))), or(or(or(equals(variables('SCIMGroupCreateTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMUserUpdateTestOutputs')['overallResult'], 'FAILED')), equals(variables('SCIMGroupUpdateTestOutputs')['overallResult'], 'FAILED')), and(contains(string(coalesce(triggerBody()?['initializationData']?['targetDirectoryUserAttributes'], json('[]'))), 'manager'), equals(variables('SCIMUpdateManagerTestOutputs')['overallResult'], 'FAILED')))), 'Failed', 'Passed')", - "workflowRunId": "@workflow().run.name" + "status": "@if(or(or(or(equals(variables('SchemaDiscoverabilityTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMNullUpdateTestOutputs')['overallResult'], 'FAILED')), or(not(equals(if(equals(parameters('selectedAuthenticationMethod'), 'OAuth2ClientCredentials'), coalesce(variables('ValidateCredentialsTestOutputs')?['overallResult'], 'FAILED'), if(equals(parameters('selectedAuthenticationMethod'), 'FederatedIdentity'), coalesce(variables('FederatedIdentityTestOutputs')?['overallResult'], 'FAILED'), 'FAILED')), 'PASSED')), equals(variables('SCIMUserCreateTestOutputs')['overallResult'], 'FAILED'))), or(or(or(equals(variables('SCIMGroupCreateTestOutputs')['overallResult'], 'FAILED'), equals(variables('SCIMUserUpdateTestOutputs')['overallResult'], 'FAILED')), equals(variables('SCIMGroupUpdateTestOutputs')['overallResult'], 'FAILED')), and(contains(string(coalesce(triggerBody()?['initializationData']?['targetDirectoryUserAttributes'], json('[]'))), 'manager'), equals(variables('SCIMUpdateManagerTestOutputs')['overallResult'], 'FAILED')))), 'Failed', 'Passed')", + "workflowRunId": "@workflow().run.name", + "authenticationModelResult": "@if(equals(parameters('selectedAuthenticationMethod'), 'OAuth2ClientCredentials'), coalesce(variables('ValidateCredentialsTestOutputs')?['overallResult'], 'FAILED'), if(equals(parameters('selectedAuthenticationMethod'), 'FederatedIdentity'), coalesce(variables('FederatedIdentityTestOutputs')?['overallResult'], 'FAILED'), 'FAILED'))", + "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')" } } }, diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md index 44b27ece..ca3315a3 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md @@ -205,8 +205,10 @@ generates submission artifacts. The agent handles the entire validation process through a simple conversation. Here’s what happens at a high level: -1. **Asks you a few questions** — Your SCIM endpoint URL, bearer token, - and authentication method, confirming Schema, Input parameters +1. **Asks you a few questions** — First choose OAuth client credentials + or Federated identity (Workload Identity Federation) for this run. + Confirm reuse or replacement of its connection parameters, then provide + your SCIM endpoint URL and bearer token, confirming Schema, Input parameters required for testing. 2. **Sets up everything automatically** — Creates the Entra app, Azure resources, Logic App, assigning permissions — all the manual portal @@ -223,6 +225,16 @@ conversation. Here’s what happens at a high level: You don’t need to navigate any portal or run any script — just answer the agent’s questions and approve the commands it runs. +For manual deployment, set the String parameter `selectedAuthenticationMethod` +to `OAuth2ClientCredentials` or `FederatedIdentity` (Workload Identity +Federation). Only that +authentication test runs; clear the unselected method's connection fields. +The selected test must return `PASSED` along with the existing required +semantic tests. Final results identify the selected authentication method. +This selects the authentication validation test, not a shared credential +provider: direct semantic calls still use `scimBearerToken`, and Entra +provisioning uses its separately configured saved connection. + ## Prerequisites Before using the agent, ensure you have: diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION index f0750610..ed9d00f9 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION @@ -1 +1 @@ -13.0 +14.0 diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md index 68db78b5..b53bf597 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md @@ -37,6 +37,10 @@ Collect the ISV's SCIM endpoint and bearer token, validate their Azure environme ### Steps +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. + 1. **Tenant whitelisting (prerequisite)** — Before proceeding with SCIM validation, the ISV must submit a tenant whitelisting request by completing the form at: 📄 **https://forms.microsoft.com/pages/responsepage.aspx?id=v4j5cvGGr0GRqy180BHbR3elR4YvzS1IhaP_XITThvJUODY1UTJSSUFXTzFYMTQ0SkxSWTY4OTYzRi4u&route=shorturl** @@ -53,7 +57,7 @@ Collect the ISV's SCIM endpoint and bearer token, validate their Azure environme b. **Bearer token** (must be long-lived — warn if JWT expires within 2 hours) - c. **Authentication method** — ask: "Does your SCIM endpoint use OAuth client credentials or a static bearer token?" + c. **Selected authentication inputs** — use the choice confirmed in Step 0: - If **OAuth**: ask for all 5 parameters, one at a time: - Client ID - Client Secret @@ -69,11 +73,10 @@ Collect the ISV's SCIM endpoint and bearer token, validate their Azure environme > Do NOT submit an empty box — empty submissions are treated as cancellation and the agent will stop. The agent treats `none` (case-insensitive) as an empty scope when writing `scimOAuthScope` to `parameters.json`. - - If **static bearer token**: record `authMethod = bearer`. The 5 OAuth fields (`scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimCredentialLocationInRequest`, `scimOAuthScope`) will be written as empty strings in Phase 4. (Logic App test behavior — including `Validate_Credentials_Test` — is out of scope for Phase 1; see Phase 4 for parameter handling and expected test behavior.) + - If **Federated identity**: set the 5 OAuth fields (`scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimCredentialLocationInRequest`, `scimOAuthScope`) to empty strings in Phase 4. d. **Federated identity test inputs (for `Federated_Identity_Test`)**: - - Ask: "Do you want to run the federated identity validation test now?" - - If **yes**, collect these values one at a time: + - If **Federated identity was selected**, collect these values one at a time: - `federatedEntraTenantId` - `federatedApplicationId` - `federatedApplicationClientSecret` @@ -81,9 +84,9 @@ Collect the ISV's SCIM endpoint and bearer token, validate their Azure environme - `federatedClientId` - `federatedBaseAddress` - `federatedAudience` (required for Google STS/SA flow, optional otherwise) - - If **no**, set all federated fields to empty strings in Phase 4 so the parameters contract remains complete. + - If **OAuth was selected**, set all federated fields to empty strings in Phase 4 so the parameters contract remains complete. - **You MUST ask these questions using `ask_user`. Do NOT assume values, do NOT skip OAuth questions, and do NOT proceed until the ISV has answered.** + **You MUST ask these questions using `ask_user`. Do NOT assume values, do NOT skip the selected method's required questions, and do NOT proceed until the ISV has answered.** 3. **Validate Azure CLI login**: ```bash @@ -357,6 +360,8 @@ else: **Hand-off to Phase 4 (do this when you reach Phase 4, not now):** +Write the confirmed `selectedAuthenticationMethod` to `parameters.json`. The bearer used by existing setup/direct SCIM actions is not an additional authentication-test selection; retain the existing setup procedure. + | Parameter | Branch A (bearer only) | Branch B (bearer + OAuth) | |---|---|---| | `scimEndpoint` | `` | `` | @@ -381,14 +386,16 @@ For federated identity testing, also pass these Phase 4 parameters: `federatedGrantType` is intentionally omitted because workflow code now derives the grant type. -**Test behavior differences:** +**Authentication test behavior (independent of the setup branches above):** -| Test | Branch A | Branch B | +| Test | OAuth selected | Federated identity selected | |---|---|---| | Core SCIM tests | Use `scimBearerToken` ✓ | Use `scimBearerToken` ✓ | -| `Validate_Credentials_Test` | **SKIPPED** (empty tokenEndpoint) | **RUNS** (exercises OAuth) | -| `Federated_Identity_Test` | **SKIPPED/FAILED based on missing federated inputs** | **RUNS** when federated inputs are populated; a non-passing result is acceptable when `Validate_Credentials_Test` succeeds | -| POD / sync engine | Uses bearer from stored secrets | Uses OAuth from stored secrets | +| `Validate_Credentials_Test` | **RUNS** using the confirmed OAuth inputs | **SKIPPED** (not selected) | +| `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. #### Step 2c: Create resource group (if needed) ```bash @@ -905,6 +912,8 @@ curl -s -H "Authorization: Bearer $TOKEN" \ Update these parameters in the JSON: | Parameter | Value | Source | |-----------|-------|--------| +| `selectedAuthenticationMethod` | `OAuth2ClientCredentials` or `FederatedIdentity` | Confirmed Phase 1 Step 0 choice. Controls the authentication test and aggregate, not the direct-test bearer. | +| `LogicAppVersion` | `` | Read the bundle's `VERSION` file; preserve this value in deployment and the final handoff. Do not infer a tested version from the current branch after a run. | | `servicePrincipalId` | `` | **Entra enterprise app SP id — NOT the Logic App MI objectId.** Both are GUIDs; swapping them silently breaks every Graph call against `/servicePrincipals//...` with a 404. | | `scimEndpoint` | `` | **MANDATORY.** The Logic App tests call this URL directly. Strip any `aadOptscim062020` feature flags. | | `scimBearerToken` | `` | **MANDATORY.** Used by both the Logic App tests (direct SCIM calls) AND by the Entra sync engine (via `SecretToken` in Step 2b). If the ISV did not provide one, abort and request it. | @@ -931,6 +940,7 @@ Update these parameters in the JSON: After patching the JSON in memory, assert every key below exists at the top level of `parameters.json` (value may be empty string for optional fields, but the key MUST be present — the workflows reference each one via `parameters('xxx')`): ``` +selectedAuthenticationMethod, LogicAppVersion, servicePrincipalId, scimEndpoint, scimBearerToken, scimContentType, testUserDomain, EnabledTests, IsSoftDeleted, defaultUserProperties, defaultGroupProperties, scimTargetUserValues, @@ -946,9 +956,9 @@ If any key is missing, abort Phase 4 and tell the ISV exactly which key is missi After the PUT completes, re-GET `parameters.json` and for each key in the patch table above, assert the returned value matches what was sent. If `servicePrincipalId` was supposed to be `aaa-bbb-ccc` but the read-back shows something else (or the key is missing), abort Phase 4 with the specific mismatch. Do NOT proceed to Phase 5. -If the ISV did not provide OAuth credentials, leave `scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimCredentialLocationInRequest`, and `scimOAuthScope` as empty strings. The `Validate_Credentials_Test` will be SKIPPED — note this as expected in the final report. This is unrelated to the Entra sync engine, which always uses the bearer token (`SecretToken`) configured in Step 2b. +If Federated identity was selected, leave `scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimCredentialLocationInRequest`, and `scimOAuthScope` as empty strings. The unselected `Validate_Credentials_Test` will be SKIPPED. If OAuth was selected, missing OAuth inputs must be resolved before deployment; a skipped selected test cannot pass validation. Provisioning still uses its separately saved connection, not the token acquired inside this test. -If the ISV did not provide federated identity inputs, leave all federated parameters as empty strings (`federatedEntraTenantId`, `federatedApplicationId`, `federatedApplicationClientSecret`, `federatedTokenEndpoint`, `federatedClientId`, `federatedBaseAddress`, `federatedAudience`). +If OAuth was selected, leave all federated parameters as empty strings (`federatedEntraTenantId`, `federatedApplicationId`, `federatedApplicationClientSecret`, `federatedTokenEndpoint`, `federatedClientId`, `federatedBaseAddress`, `federatedAudience`). If Federated identity was selected, resolve its required inputs before deployment. #### defaultUserProperties handling (CRITICAL — do NOT build from scratch) @@ -1226,7 +1236,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` | `"PASSED"` \| `"FAILED"` | `"PASSED"` when at least one of `Validate_Credentials_Test` or `Federated_Identity_Test` succeeded. `"FAILED"` when neither passed — also trips `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. | ### Step 6b: Parse each test result @@ -1256,7 +1266,7 @@ Test result semantics: - **`"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"`. -**Authentication pair exception:** `Validate_Credentials_Test` and `Federated_Identity_Test` are `Mandatory/StrictOneSuccess`, not independently strict. Evaluate them as a pair using `authenticationModelResult`. When `authenticationModelResult == "PASSED"`, one model succeeded and the other model may be skipped or may contain a diagnostic non-success result such as `"Entra token acquisition failed"`. Do not classify that non-passing counterpart as a failed validation test, do not block submission, and do not include it in `Tests failed`. When `authenticationModelResult == "FAILED"`, authentication fails and submission is blocked. +**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. ### Step 6c: Drill into child workflow actions to find the real error (BEFORE pattern matching) @@ -1471,9 +1481,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"` (at least one auth model succeeded — this is a hard requirement). + - `Evaluate_Test_Results.authenticationModelResult == "PASSED"` (the selected authentication test succeeded — this is a hard requirement). - Every **non-authentication** entry in `Final_TestResults.testResults[]` has `testResult` starting with `"success"`, `"SKIPPED"`, or `"WARNING"` — NEVER `"FAILED"`. - - Treat `Validate_Credentials_Test` and `Federated_Identity_Test` as one `Mandatory/StrictOneSuccess` authentication pair. Because `authenticationModelResult == "PASSED"` is already required, a skipped or non-passing counterpart (including a diagnostic result such as `"Entra token acquisition failed"`) is acceptable and does not block submission. Report which authentication model passed, but do not count the counterpart in `Tests 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. - `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 new file mode 100644 index 00000000..aa80e268 --- /dev/null +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md @@ -0,0 +1,115 @@ +# Local authentication-selection regression tests + +## 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. +It is developer verification, not a deployed workflow or a prerequisite for an +ISV's cloud validation run. + +The tests do not contact Azure, Microsoft Graph, an authorization server or a +SCIM endpoint. They do not require credentials, create resources or run +provisioning. Passing these checks is not proof of a successful live OAuth or +federation exchange, or full gallery readiness. + +## Prerequisites + +- Node.js with the built-in `node:test` runner and `structuredClone`. Node.js + 22 or later is recommended. No npm packages or `npm install` are required. +- Git available on `PATH`. +- A repository checkout or worktree with the pre-feature baseline commit + `8bdf02e700b8c4e667c6eee6924010274a58af8f` available in its local history. + A downloaded source ZIP or shallow clone missing that commit is insufficient + for the preservation checks. + +`.cjs` identifies a CommonJS JavaScript file. The test uses Node.js built-in +modules only. + +## Where and how to run + +Open a PowerShell terminal at the **repository/worktree root**, the folder that +contains `Microsoft.SCIM.LogicAppValidationTemplate`, and run: + +```powershell +node --test .\Microsoft.SCIM.LogicAppValidationTemplate\StandardLogicApp\tests\minimal-auth-selection.test.cjs +``` + +Alternatively, from this `tests` directory: + +```powershell +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 four tests. + +Expected summary: + +```text +tests 4 +pass 4 +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 four 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 scripts remain unchanged; documentation uses the selector name; the version parameter agrees with VERSION; final results identify the choice | + +## How workflow expressions are checked + +The file reads conditions and result expressions directly from the workflow +definitions rather than keeping separate JavaScript copies of their logic. +A small evaluator supplies simulated parameter values, variables and action +outputs for the expression subset exercised by these tests. + +This helper is **not the Azure Logic Apps engine**. It does not execute HTTP +actions, reproduce every expression-language rule, model lazy branch evaluation, +or validate scheduling, retries, managed identity, token lifetime or deployment. +Expression changes outside its supported subset may require a deliberate helper +update and a real runtime check. + +## Version and baseline maintenance + +### Bundle version: do not hard-code a release number in the test + +The test reads [VERSION](../VERSION), checks that it is nonempty, and compares it +with `LogicAppVersion.value` in +[Orchestrator_Parameters.json](../Orchestrator_Parameters.json). It does not assert +a literal release such as `14.0`. + +For a future version bump, update the authoritative VERSION file and the existing +parameter default together. The test then verifies consistency without needing a +third version edit. It does not enforce a version-format policy or prove that a +release number increased. + +### Comparison baseline: intentionally pinned + +The Git revision `8bdf02e` is intentionally fixed. It is the source baseline from +before the selection feature, not a release number. Reading whatever happens to +be latest master would make the preservation assertions change without review. + +Future intentional protocol or semantic changes may require updating these +preservation assertions and their baseline. Review those changes explicitly; +do not simply move the baseline to hide a failing assertion. + +## Troubleshooting + +| Symptom | Next action | +|---|---| +| `node` or `git` not found | Install or select the prerequisite and reopen the terminal so its PATH is available | +| Unknown Git revision / missing baseline object | Use a normal repository checkout with the baseline commit in local history | +| Version assertion fails | Compare VERSION with the shared `LogicAppVersion` default; do not restore a hard-coded test version | +| Workflow-preservation assertion fails | Inspect the named action/file and decide whether its change is intentional | +| Expression parser/assertion fails | Inspect the expression and the helper's supported subset; do not assume this alone establishes a cloud runtime failure | + +Use a separate authorized, isolated Logic App run to establish actual cloud +behavior. Keep its source version, deployed hashes, exclusions and native +results distinct from these local test results. diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs new file mode 100644 index 00000000..27784e91 --- /dev/null +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs @@ -0,0 +1,151 @@ +/* + * Local regression checks against workflow JSON, not the Azure Logic Apps runtime. + * No cloud calls or endpoint credentials are used. See README.md in this directory. + * From the repository/worktree root: + * node --test .\Microsoft.SCIM.LogicAppValidationTemplate\StandardLogicApp\tests\minimal-auth-selection.test.cjs + */ +const assert = require('node:assert/strict'); +const {test} = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const {execFileSync} = require('node:child_process'); +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})); +const child = load('SCIMTests_Workflow.json').definition.actions; +const parent = load('Orchestrator_Workflow.json').definition.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]; + const values = args.map(value => evaluate(value, context)); + return op === 'and' ? values.every(Boolean) : op === 'or' ? values.some(Boolean) : values[0] === values[1]; + } + if (typeof expression !== 'string' || !expression.startsWith('@')) return expression; + const source = expression.slice(1); + let position = 0; + const functions = { + 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), + 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, + }; + const space = () => { while (position < source.length && /\s/.test(source[position])) position++; }; + function value() { + space(); + let result; + if (source[position] === "'") { + const start = ++position; + while (source[position] !== "'") { if (position >= source.length) throw Error('Unclosed string'); position++; } + result = source.slice(start, position++); + } else { + const match = /^[A-Za-z_]\w*/.exec(source.slice(position)); + assert.ok(match, source.slice(position)); + position += match[0].length; + space(); + if (source[position] === '(') { + position++; + const args = []; + space(); + while (source[position] !== ')') { + args.push(value()); space(); + if (source[position] !== ',') break; + position++; + } + assert.equal(source[position++], ')'); + assert.ok(functions[match[0]], match[0]); + result = functions[match[0]](...args); + } else result = {true: true, false: false, null: null}[match[0]]; + } + space(); + while (source[position] === '?' || source[position] === '[') { + if (source[position] === '?') position++; + assert.equal(source[position++], '['); + const key = value(); + assert.equal(source[position++], ']'); + result = result?.[key]; + space(); + } + return result; + } + const result = value(); + assert.equal(position, source.length, source.slice(position)); + return result; +} +const methods = ['OAuth2ClientCredentials', 'FederatedIdentity']; +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}}; + 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)); + } + } + } +}); +test('selected missing, skipped or failed output cannot be rescued by the other method', () => { + for (const [index, method] of methods.entries()) { + for (const outcome of ['PASSED', 'FAILED', 'SKIPPED', undefined]) { + // 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}); + assert.equal(actual === 'PASSED', outcome === 'PASSED'); + } + } + assert.equal(evaluate(child.Compose_Final_Results.inputs.authenticationModelResult, {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); + 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; + outputs[`Get_${file.replace('_Workflow.json', '')}_Output_Content`] = + Object.fromEntries(Object.keys(final).filter(key => key.endsWith('Outputs')).map(key => [key, {overallResult: 'PASSED'}])); + } + 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'); + } + context.bodies.Get_SCIMTests_Output_Content.authenticationModelResult = 'PASSED'; + context.bodies.Get_UserTests_Output_Content.CreateUserTestOutputs.overallResult = 'FAILED'; + assert.equal(evaluate(current, context), 'Failed'); +}); +test('token protocols and 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; + 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)); + // 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'); + assert.equal(load('Orchestrator_Parameters.json').LogicAppVersion.value, version); + assert.equal(load('Orchestrator_Parameters.json').selectedAuthenticationMethod.value, ''); + for (const name of ['Deploy-LogicAppWorkflows.ps1', 'ValidateLogicAppRun-Standard.ps1']) { + assert.equal(fs.readFileSync(path.join(root, name), 'utf8').replace(/\r\n/g, '\n'), + execFileSync('git', ['show', `8bdf02e:Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/${name}`], {cwd: root, encoding: 'utf8'}).replace(/\r\n/g, '\n')); + } + for (const name of ['scim-onboarding.agent.md', 'SetupLogicApp-Standard-Agent.md', 'SCIM-Validation-Test-Overview.md']) { + const text = fs.readFileSync(path.join(root, name), 'utf8'); + assert.ok(text.includes('`FederatedIdentity`'), name); + assert.ok(!text.includes('`WorkloadIdentityFederation`'), name); + } + assert.equal(parent.Final_TestResults.inputs.selectedAuthenticationMethod, "@parameters('selectedAuthenticationMethod')"); +}); From 3f99d4f4349440c734a9f158a70f9168aa49fb4a Mon Sep 17 00:00:00 2001 From: "Prashant Rane (ALLYIS INC)" Date: Wed, 21 Oct 2026 08:51:08 -0600 Subject: [PATCH 2/2] Include bundle version and validation timing in SCIM results Use VERSION as the single source for the deployed LogicAppVersion. Populate the runtime parameter during PowerShell and agent-led deployment, leave the source template version blank, and include the deployed version in the Orchestrator's native final results. Capture validation start and completion timestamps once in existing Orchestrator actions. Include their UTC values, Unix epoch seconds and elapsed whole seconds in final results. These are validation milestones, not the exact Azure run-history start and end timestamps. Validate version preparation and deployed read-back, reject parameter-skip deployments that could leave stale version metadata, and update setup, agent and test documentation. Add offline deployment and timing regression coverage without changing authentication or provisioning behavior. Validation: six local regression tests pass. Agent parameter-generation, JSON and whitespace checks pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Deploy-LogicAppWorkflows.ps1 | 85 +++++++++++++++++-- .../Orchestrator_Parameters.json | 4 +- .../Orchestrator_Workflow.json | 12 ++- .../SCIM-Validation-Test-Overview.md | 12 +++ .../SetupLogicApp-Standard-Agent.md | 18 ++++ .../StandardLogicApp/scim-onboarding.agent.md | 40 ++++++--- .../StandardLogicApp/tests/README.md | 46 ++++++---- .../tests/minimal-auth-selection.test.cjs | 43 +++++++++- .../tests/version-deployment.test.ps1 | 75 ++++++++++++++++ 9 files changed, 293 insertions(+), 42 deletions(-) create mode 100644 Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/version-deployment.test.ps1 diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Deploy-LogicAppWorkflows.ps1 b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Deploy-LogicAppWorkflows.ps1 index 30cd64c1..77529657 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Deploy-LogicAppWorkflows.ps1 +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Deploy-LogicAppWorkflows.ps1 @@ -5,7 +5,8 @@ .DESCRIPTION This script reads *_Workflow.json files from a specified directory and creates/updates workflows in an Azure Logic App Standard resource using the Azure REST API (via az CLI). - It also optionally deploys a parameters.json file for workflow parameters. + It also deploys parameters.json, deriving LogicAppVersion from VERSION in WorkflowsPath. + The source parameters file is not modified. VERSION must come from the same source bundle. The script performs pre-deployment validation of all files before making any API calls, ensuring no partial deployments occur if any file is invalid. @@ -26,6 +27,10 @@ (Optional) Path to a parameters JSON file to deploy as the app-level parameters.json. If not specified, the script will look for *_Parameters.json in the WorkflowsPath. +.PARAMETER SkipParameters + Retained for command-line compatibility, but rejected for versioned bundles. + Deploy parameters with workflows so final results cannot report a stale version. + .EXAMPLE .\Deploy-LogicAppWorkflows.ps1 -SubscriptionId "xxxx" -ResourceGroup "myRG" -LogicAppName "myLogicApp" @@ -34,7 +39,7 @@ .EXAMPLE .\Deploy-LogicAppWorkflows.ps1 -SubscriptionId "xxxx" -ResourceGroup "myRG" -LogicAppName "myLogicApp" -SkipParameters - Deploys only workflow files without updating the parameters.json. + Rejected for versioned bundles: parameters must be deployed with the workflow version. .NOTES Prerequisites: @@ -268,7 +273,8 @@ function Deploy-VfsFile { [string]$RG, [string]$AppName, [string]$RemotePath, - [string]$LocalFilePath + [string]$LocalFilePath, + [string]$Content ) # ARM-proxied VFS endpoint — routes through management.azure.com instead of SCM directly @@ -284,7 +290,7 @@ function Deploy-VfsFile { } # Read the raw file content — preserves exact bytes including Logic Apps expressions - $bodyContent = Get-Content -Path $LocalFilePath -Raw -Encoding UTF8 + $bodyContent = if ($PSBoundParameters.ContainsKey('Content')) { $Content } else { Get-Content -Path $LocalFilePath -Raw -Encoding UTF8 } # Use Invoke-RestMethod to send raw content without JSON re-serialization $headers = @{ @@ -295,6 +301,13 @@ function Deploy-VfsFile { $result = Invoke-RestMethod -Method PUT -Uri $vfsUri -Headers $headers -Body $bodyContent -ContentType "application/json" + if ($RemotePath -eq "parameters.json") { + $readback = Invoke-RestMethod -Method GET -Uri $vfsUri -Headers $headers + if ($readback.LogicAppVersion.value -cne $script:BundleVersion) { + throw "Deployed LogicAppVersion does not match VERSION. Do not start the workflows." + } + } + return $true } catch { @@ -351,6 +364,23 @@ function Deploy-Workflow { } } +function Get-VersionedParametersContent { + param( + [string]$ParametersFilePath, + [string]$BundleVersion + ) + + $parameters = Get-Content -LiteralPath $ParametersFilePath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($null -eq $parameters -or $parameters.LogicAppVersion.type -cne 'String') { + throw "Parameters must define LogicAppVersion with type String." + } + if ([string]::IsNullOrWhiteSpace($BundleVersion)) { + throw "VERSION must contain a nonempty bundle version." + } + $parameters.LogicAppVersion | Add-Member -NotePropertyName value -NotePropertyValue $BundleVersion.Trim() -Force + return $parameters | ConvertTo-Json -Depth 100 +} + function Deploy-Parameters { <# .SYNOPSIS @@ -366,7 +396,7 @@ function Deploy-Parameters { Write-LogMessage "Deploying parameters file: $ParametersFilePath" -Level Info try { - $paramsContent = Get-Content -Path $ParametersFilePath -Raw -Encoding UTF8 + $paramsContent = Get-VersionedParametersContent -ParametersFilePath $ParametersFilePath -BundleVersion $script:BundleVersion $paramsObj = $paramsContent | ConvertFrom-Json # Validate it's a valid JSON object @@ -382,9 +412,9 @@ function Deploy-Parameters { try { # Upload parameters.json to root via ARM VFS - Deploy-VfsFile -SubId $SubId -RG $RG -AppName $AppName ` + $null = Deploy-VfsFile -SubId $SubId -RG $RG -AppName $AppName ` -RemotePath "parameters.json" ` - -LocalFilePath $ParametersFilePath + -Content $paramsContent Write-LogMessage " Parameters file deployed successfully." -Level Success return $true @@ -412,6 +442,23 @@ if (-not $WorkflowsPath) { exit 1 } +try { + if ($SkipParameters) { + throw "-SkipParameters is not supported for versioned bundles. Deploy parameters with the workflows to avoid stale version metadata." + } + if ($ParametersFile -and -not (Test-Path -LiteralPath $ParametersFile -PathType Leaf)) { + throw "Parameters file does not exist: $ParametersFile" + } + $script:BundleVersion = (Get-Content -LiteralPath (Join-Path $WorkflowsPath 'VERSION') -Raw -Encoding UTF8).Trim() + if ([string]::IsNullOrWhiteSpace($script:BundleVersion)) { + throw "VERSION must contain a nonempty bundle version." + } +} +catch { + Write-LogMessage "Version preparation failed: $_" -Level Error + exit 1 +} + Write-LogMessage "Configuration:" -Level Info Write-Host " Subscription ID : $SubscriptionId" Write-Host " Resource Group : $ResourceGroup" @@ -482,6 +529,12 @@ if ($SkipParameters) { } elseif ($ParametersFile -and (Test-Path $ParametersFile)) { $pErrors = Test-ParametersFile -FilePath $ParametersFile -FileName (Split-Path $ParametersFile -Leaf) + try { + $null = Get-VersionedParametersContent -ParametersFilePath $ParametersFile -BundleVersion $script:BundleVersion + } + catch { + $pErrors += "Version preparation failed: $_" + } if ($pErrors.Count -gt 0) { $validationFailed = $true foreach ($err in $pErrors) { @@ -494,8 +547,18 @@ elseif ($ParametersFile -and (Test-Path $ParametersFile)) { } else { $autoParamFiles = Get-ParametersFiles -Path $WorkflowsPath + if ($autoParamFiles.Count -eq 0) { + Write-LogMessage "A parameters file is required to deploy the bundle version." -Level Error + $validationFailed = $true + } foreach ($pf in $autoParamFiles) { $pErrors = Test-ParametersFile -FilePath $pf.FullName -FileName $pf.Name + try { + $null = Get-VersionedParametersContent -ParametersFilePath $pf.FullName -BundleVersion $script:BundleVersion + } + catch { + $pErrors += "Version preparation failed: $_" + } if ($pErrors.Count -gt 0) { $validationFailed = $true foreach ($err in $pErrors) { @@ -535,12 +598,20 @@ else { foreach ($pf in $paramFiles) { Write-LogMessage " Found: $($pf.Name)" -Level Info $paramsDeployed = Deploy-Parameters -SubId $SubscriptionId -RG $ResourceGroup -AppName $LogicAppName -ParametersFilePath $pf.FullName + if (-not $paramsDeployed) { + Write-LogMessage "Parameters deployment or version read-back failed. No workflows will be uploaded." -Level Error + exit 1 + } } } else { Write-LogMessage "Step 6: No parameters file found. Skipping parameters deployment." -Level Info } } +if (-not $paramsDeployed) { + Write-LogMessage "Parameters deployment or version read-back failed. No workflows will be uploaded." -Level Error + exit 1 +} Write-Host "" # --- Step 7: Deploy workflows --- diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json index f11545d6..e03b633c 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json @@ -43,10 +43,10 @@ }, "LogicAppVersion": { "metadata": { - "description": "Version of the Logic App for tracking and deployment purposes" + "description": "Populated during deployment from VERSION in the same source bundle." }, "type": "String", - "value": "14.0" + "value": "" }, "attributeTransforms": { "metadata": { diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json index 970d2432..6d89713d 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Workflow.json @@ -23,6 +23,7 @@ "workflowId": "@workflow().id", "workflowName": "@workflow().name", "workflowRunName": "@workflow().run.name", + "validationStartedAtUtc": "@utcNow()", "workflowIdSplit": "@split(workflow().id, '/')", "workflowIdSplitLength": "@length(split(workflow().id, '/'))" } @@ -506,7 +507,8 @@ }, "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')", - "authenticationModelResult": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'], 'FAILED')" + "authenticationModelResult": "@coalesce(body('Get_SCIMTests_Output_Content')?['authenticationModelResult'], 'FAILED')", + "validationCompletedAtUtc": "@utcNow()" } }, "Final_TestResults": { @@ -752,7 +754,13 @@ }, "authenticationModelResult": "@outputs('Evaluate_Test_Results')?['authenticationModelResult']", "overallResult": "@outputs('Evaluate_Test_Results')?['overallLogicAppResult']", - "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')" + "selectedAuthenticationMethod": "@parameters('selectedAuthenticationMethod')", + "LogicAppVersion": "@parameters('LogicAppVersion')", + "validationStartedAtUtc": "@outputs('Compose_Debug_WorkflowObject')?['validationStartedAtUtc']", + "validationStartedAtEpochSeconds": "@div(sub(ticks(outputs('Compose_Debug_WorkflowObject')?['validationStartedAtUtc']), ticks('1970-01-01T00:00:00Z')), 10000000)", + "validationCompletedAtUtc": "@outputs('Evaluate_Test_Results')?['validationCompletedAtUtc']", + "validationCompletedAtEpochSeconds": "@div(sub(ticks(outputs('Evaluate_Test_Results')?['validationCompletedAtUtc']), ticks('1970-01-01T00:00:00Z')), 10000000)", + "validationDurationSeconds": "@div(sub(ticks(outputs('Evaluate_Test_Results')?['validationCompletedAtUtc']), ticks(outputs('Compose_Debug_WorkflowObject')?['validationStartedAtUtc'])), 10000000)" } }, "Terminate_With_Test_Status": { diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md index 09225abb..3f23e7a5 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md @@ -89,6 +89,18 @@ Tests that don't apply are reported as **SKIPPED** (not failures). However, grou ## What "Passing" Means +### Version and validation timing + +`Final_TestResults` includes the deployed `LogicAppVersion`, derived during +deployment from the same bundle's VERSION file. It also includes +`validationStartedAtUtc` and `validationCompletedAtUtc`, their corresponding +`validationStartedAtEpochSeconds` and `validationCompletedAtEpochSeconds`, +and `validationDurationSeconds`. UTC timestamps are captured once at the first +Compose and at result evaluation; numeric values are derived from those captures. +Epoch seconds count from 1970-01-01 UTC, and duration is elapsed whole seconds. +These fields are not Azure's authoritative run-history start/end times and do +not affect pass/fail. Runs that never reach the final action have no final JSON. + ### Overall Result Logic The Orchestrator's `Evaluate_Test_Results` action computes `overallLogicAppResult` as follows: diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md index ca3315a3..cb019d0a 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md @@ -722,6 +722,14 @@ style="width:6.5in;height:2.60972in" /> 16. Run the following command +Keep `VERSION` beside the workflow files in the selected `WorkflowsPath`. +The script reads that file, populates `LogicAppVersion` in the uploaded +parameters without modifying the source template, and verifies the version +by reading back deployed parameters. Missing/empty VERSION or a version +read-back mismatch stops deployment. `-SkipParameters` is rejected to avoid +deploying workflows with stale version metadata. VERSION and workflows must +come from the same source revision. No additional runtime package is needed. + .\Deploy-LogicAppWorkflows.ps1 \` -SubscriptionId \$subscriptionId \` @@ -1058,6 +1066,16 @@ style="width:6.5in;height:2.60764in" /> In Final_TestResults -\> Select 'Show raw Outputs’. +The native output includes `LogicAppVersion` from the deployed parameters. +`validationStartedAtUtc` is captured by the first Orchestrator Compose; +`validationCompletedAtUtc` is captured during result evaluation after results +are collected. The matching `validationStartedAtEpochSeconds`, +`validationCompletedAtEpochSeconds`, and `validationDurationSeconds` are +integer seconds derived from those same captures, with subsecond fractions +truncated. These are validation milestones, not the exact Azure run-history +start/end timestamps. The final action executes before workflow termination; +early failures or cancellations may not produce final output. + diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md index b53bf597..18c49655 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md @@ -427,7 +427,7 @@ az webapp identity assign --name "" --resource-group "rg-scim-vali Note the `principalId` — this is the **managedIdentityObjectId**. #### Step 2f: Deploy workflow templates -The Logic App has 5 workflows + 1 parameters file. Deploy them via the Kudu VFS API. +The Logic App has 5 workflows + 1 parameters file, plus VERSION for deployment metadata. Deploy the JSON files via the Kudu VFS API. **File acquisition (REQUIRED — try GitHub first, fall back to local):** @@ -437,10 +437,12 @@ The canonical source of truth for the template files is: Raw download base: `https://raw.githubusercontent.com/AzureAD/SCIMReferenceCode/master/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/` -For each of the 6 files below: +Acquire the seven files below from the same immutable source revision. Resolve the selected GitHub branch to a commit before downloading and use that commit in the raw URL. For a local feature bundle, use all files from that local bundle instead. Do not mix upstream workflows with a local VERSION or mix per-file fallback sources. If acquisition fails, restart acquisition of the complete bundle from one source. -1. **First, attempt the GitHub raw URL.** Use `curl -fsSL -o ` (or the agent's web fetch tool). Treat HTTP 200 as success. If the response is HTTP 404/403/5xx or a network error, fall back to step 2. -2. **Fallback: read the file from the local workspace** (the same folder that contains this `scim-onboarding.agent.md`). If neither source returns the file, abort Phase 2 and tell the ISV which file is missing. +For each of the 7 files below: + +1. **First, attempt the pinned GitHub raw URLs for the complete bundle.** Use `curl -fsSL -o ` (or the agent's web fetch tool). Treat HTTP 200 as success. +2. **Fallback: reacquire the complete bundle from the local workspace** (the same folder that contains this `scim-onboarding.agent.md`). If neither complete source is available, abort Phase 2 and tell the ISV which file is missing. Never deploy a partially downloaded or mixed-source bundle. Files to acquire (exact names, case-sensitive): @@ -452,23 +454,21 @@ Files to acquire (exact names, case-sensitive): | `GroupTests_Workflow.json` | `GroupTests_Workflow/workflow.json` | | `SCIMTests_Workflow.json` | `SCIMTests_Workflow/workflow.json` | | `Orchestrator_Parameters.json` | basis for `parameters.json` (after override, see below) | +| `VERSION` | authoritative version used to populate `LogicAppVersion`; not uploaded as a workflow | Example acquisition loop the agent must follow before any upload: -done ```bash -BASE_RAW="https://raw.githubusercontent.com/AzureAD/SCIMReferenceCode/master/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp" -FILES=(Orchestrator_Workflow.json Initialization_Workflow.json UserTests_Workflow.json GroupTests_Workflow.json SCIMTests_Workflow.json Orchestrator_Parameters.json) +BASE_RAW="https://raw.githubusercontent.com/AzureAD/SCIMReferenceCode//Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp" +FILES=(Orchestrator_Workflow.json Initialization_Workflow.json UserTests_Workflow.json GroupTests_Workflow.json SCIMTests_Workflow.json Orchestrator_Parameters.json VERSION) for f in "${FILES[@]}"; do if curl -fsSL "$BASE_RAW/$f" -o "$f"; then echo " [github] $f" - elif [ -f "./$f" ]; then - echo " [local] $f (github fetch failed)" else - echo " [MISSING] $f — abort"; exit 1 + echo " [MISSING] $f — stop and reacquire the complete local bundle"; exit 1 fi - +done ``` Always log to the ISV which source (`github` vs `local`) was used per file, so they know whether they are deploying the upstream version or a locally modified one. @@ -542,13 +542,18 @@ curl -X PUT \ # Use jq or python to do the replacement: cat Orchestrator_Parameters.json | python3 -c " import sys, json +from pathlib import Path p = json.load(sys.stdin) +version = Path('VERSION').read_text(encoding='utf-8-sig').strip() +if not version: + sys.exit('VERSION must contain a nonempty bundle version') +p['LogicAppVersion']['value'] = version p['scimEndpoint']['value'] = '' p['scimBearerToken']['value'] = '' p['servicePrincipalId']['value'] = '' p['testUserDomain']['value'] = '' json.dump(p, sys.stdout, indent=2) -" > parameters_override.json +" > parameters_override.json || exit 1 curl -X PUT \ "https://management.azure.com/subscriptions//resourceGroups//providers/Microsoft.Web/sites//extensions/api/vfs/site/wwwroot/parameters.json?api-version=2022-03-01" \ @@ -952,6 +957,8 @@ federatedTokenEndpoint, federatedClientId, federatedBaseAddress, federatedAudien If any key is missing, abort Phase 4 and tell the ISV exactly which key is missing. Do NOT PUT a parameters.json that fails this check. +Read VERSION from the acquired bundle on every parameters write, trim it, reject a missing/empty value, and overwrite `LogicAppVersion.value`. Do not use the blank template value or infer a version from another checkout. Before starting a run, verify the deployed read-back version equals the acquired VERSION. + #### Post-PUT read-back verification (MANDATORY) After the PUT completes, re-GET `parameters.json` and for each key in the patch table above, assert the returned value matches what was sent. If `servicePrincipalId` was supposed to be `aaa-bbb-ccc` but the read-back shows something else (or the key is missing), abort Phase 4 with the specific mismatch. Do NOT proceed to Phase 5. @@ -1230,6 +1237,15 @@ az rest --method GET \ Then fetch the output content from the `outputsLink.uri` in the response. +The native final JSON includes `LogicAppVersion`, `validationStartedAtUtc`, +`validationCompletedAtUtc`, the corresponding `validationStartedAtEpochSeconds` +and `validationCompletedAtEpochSeconds`, and `validationDurationSeconds`. +The start is captured in the first Compose action; completion is captured during +result evaluation. These are validation milestones, not Azure's exact run-history +start/end times. Epoch and duration values are integer seconds derived from those +same UTC captures. Runs that never reach the final action have no final JSON. +Report these values from the exact run, not from the current local VERSION. + The sibling `Evaluate_Test_Results` action on the same run also exposes three top-level aggregation fields (fetch its `outputsLink.uri` the same way): | Field | Values | Meaning | diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md index aa80e268..48a84492 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/README.md @@ -17,6 +17,8 @@ federation exchange, or full gallery readiness. - Node.js with the built-in `node:test` runner and `structuredClone`. Node.js 22 or later is recommended. No npm packages or `npm install` are required. - 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 downloaded source ZIP or shallow clone missing that commit is insufficient @@ -41,27 +43,39 @@ 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 four tests. +terminal's current directory. The two commands run the same six tests. Expected summary: ```text -tests 4 -pass 4 +tests 6 +pass 6 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 four tests check +## What the six 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 scripts remain unchanged; documentation uses the selector name; the version parameter agrees with VERSION; final results identify the choice | +| 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 | +| 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 +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: + +```powershell +pwsh -NoProfile -File .\version-deployment.test.ps1 +``` ## How workflow expressions are checked @@ -72,7 +86,7 @@ outputs for the expression subset exercised by these tests. This helper is **not the Azure Logic Apps engine**. It does not execute HTTP actions, reproduce every expression-language rule, model lazy branch evaluation, -or validate scheduling, retries, managed identity, token lifetime or deployment. +or validate scheduling, retries, managed identity, token lifetime or live deployment. Expression changes outside its supported subset may require a deliberate helper update and a real runtime check. @@ -80,15 +94,17 @@ update and a real runtime check. ### Bundle version: do not hard-code a release number in the test -The test reads [VERSION](../VERSION), checks that it is nonempty, and compares it -with `LogicAppVersion.value` in -[Orchestrator_Parameters.json](../Orchestrator_Parameters.json). It does not assert -a literal release such as `14.0`. +The test reads [VERSION](../VERSION) and checks that it is nonempty. +[Orchestrator_Parameters.json](../Orchestrator_Parameters.json) intentionally has +an empty `LogicAppVersion.value`; deployment generates its value from VERSION. +The offline PowerShell tests check injection, LF/CRLF trimming, future-version +values, preservation of nested data/expressions, unchanged source files, +missing/empty inputs, version read-back mismatch, and rejection of +`-SkipParameters`. No literal release number is asserted. -For a future version bump, update the authoritative VERSION file and the existing -parameter default together. The test then verifies consistency without needing a -third version edit. It does not enforce a version-format policy or prove that a -release number increased. +For a future version bump, update only VERSION. The generated upload and native +result carry that value; the source template remains blank. Tests do not enforce +a version-format policy or prove that a release number increased. ### Comparison baseline: intentionally pinned @@ -106,7 +122,7 @@ do not simply move the baseline to hide a failing assertion. |---|---| | `node` or `git` not found | Install or select the prerequisite and reopen the terminal so its PATH is available | | Unknown Git revision / missing baseline object | Use a normal repository checkout with the baseline commit in local history | -| Version assertion fails | Compare VERSION with the shared `LogicAppVersion` default; do not restore a hard-coded test version | +| Version assertion fails | Verify VERSION is nonempty, the template version is blank, and injection/read-back checks pass; do not hard-code a release number | | Workflow-preservation assertion fails | Inspect the named action/file and decide whether its change is intentional | | Expression parser/assertion fails | Inspect the expression and the helper's supported subset; do not assume this alone establishes a cloud runtime failure | 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 27784e91..af46c664 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/minimal-auth-selection.test.cjs @@ -36,6 +36,13 @@ function evaluate(expression, context) { 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, + ticks: value => { + const fraction = /\.(\d+)Z$/.exec(value)?.[1] || ''; + return BigInt(Date.parse(value.replace(/\.\d+Z$/, 'Z'))) * 10000n + + BigInt(fraction.padEnd(7, '0')) + 621355968000000000n; + }, + sub: (a, b) => a - b, + div: (a, b) => Number(a / BigInt(b)), }; const space = () => { while (position < source.length && /\s/.test(source[position])) position++; }; function value() { @@ -46,7 +53,7 @@ function evaluate(expression, context) { while (source[position] !== "'") { if (position >= source.length) throw Error('Unclosed string'); position++; } result = source.slice(start, position++); } else { - const match = /^[A-Za-z_]\w*/.exec(source.slice(position)); + const match = /^(?:[A-Za-z_]\w*|\d+)/.exec(source.slice(position)); assert.ok(match, source.slice(position)); position += match[0].length; space(); @@ -62,7 +69,7 @@ function evaluate(expression, context) { assert.equal(source[position++], ')'); assert.ok(functions[match[0]], match[0]); result = functions[match[0]](...args); - } else result = {true: true, false: false, null: null}[match[0]]; + } else result = /^\d+$/.test(match[0]) ? Number(match[0]) : {true: true, false: false, null: null}[match[0]]; } space(); while (source[position] === '?' || source[position] === '[') { @@ -136,9 +143,9 @@ test('token protocols and semantic source remain unchanged; naming and bundle ve // 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'); - assert.equal(load('Orchestrator_Parameters.json').LogicAppVersion.value, version); + assert.equal(load('Orchestrator_Parameters.json').LogicAppVersion.value, ''); assert.equal(load('Orchestrator_Parameters.json').selectedAuthenticationMethod.value, ''); - for (const name of ['Deploy-LogicAppWorkflows.ps1', 'ValidateLogicAppRun-Standard.ps1']) { + for (const name of ['ValidateLogicAppRun-Standard.ps1']) { assert.equal(fs.readFileSync(path.join(root, name), 'utf8').replace(/\r\n/g, '\n'), execFileSync('git', ['show', `8bdf02e:Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/${name}`], {cwd: root, encoding: 'utf8'}).replace(/\r\n/g, '\n')); } @@ -149,3 +156,31 @@ test('token protocols and semantic source remain unchanged; naming and bundle ve } assert.equal(parent.Final_TestResults.inputs.selectedAuthenticationMethod, "@parameters('selectedAuthenticationMethod')"); }); + +test('final result uses the deployed version and two single-capture validation timestamps', () => { + const final = parent.Final_TestResults.inputs; + assert.equal(final.LogicAppVersion, "@parameters('LogicAppVersion')"); + assert.equal(parent.Compose_Debug_WorkflowObject.inputs.validationStartedAtUtc, '@utcNow()'); + assert.equal(parent.Evaluate_Test_Results.inputs.validationCompletedAtUtc, '@utcNow()'); + for (const [start, end, seconds] of [ + ['2026-10-21T13:00:00Z', '2026-10-21T13:33:12Z', 1992], + ['2026-10-21T23:59:59.9000000Z', '2026-10-22T00:00:01.1000000Z', 1], + ]) { + const context = {parameters: {LogicAppVersion: 'future-version'}, outputs: { + Compose_Debug_WorkflowObject: {validationStartedAtUtc: start}, + Evaluate_Test_Results: {validationCompletedAtUtc: end}, + }}; + assert.equal(evaluate(final.LogicAppVersion, context), 'future-version'); + assert.equal(evaluate(final.validationStartedAtUtc, context), start); + assert.equal(evaluate(final.validationCompletedAtUtc, context), end); + assert.equal(evaluate(final.validationStartedAtEpochSeconds, context), Math.floor(Date.parse(start) / 1000)); + assert.equal(evaluate(final.validationCompletedAtEpochSeconds, context), Math.floor(Date.parse(end) / 1000)); + assert.equal(evaluate(final.validationDurationSeconds, context), seconds); + } +}); + +test('PowerShell deployment derives version without modifying source parameters', () => { + const output = execFileSync('pwsh', ['-NoProfile', '-File', path.join(__dirname, 'version-deployment.test.ps1')], + {cwd: root, encoding: 'utf8'}); + assert.ok(output.includes('Version deployment checks passed'), output); +}); diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/version-deployment.test.ps1 b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/version-deployment.test.ps1 new file mode 100644 index 00000000..4fb0158c --- /dev/null +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/tests/version-deployment.test.ps1 @@ -0,0 +1,75 @@ +# Run locally with pwsh -NoProfile -File ./version-deployment.test.ps1. +# Load only function definitions: never execute the deployment script's main/cloud path. +$ErrorActionPreference = 'Stop' +$bundle = Split-Path $PSScriptRoot -Parent +$scriptPath = Join-Path $bundle 'Deploy-LogicAppWorkflows.ps1' +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +foreach ($name in @('Get-VersionedParametersContent', 'Deploy-VfsFile', 'Deploy-Parameters')) { + $function = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) + if (-not $function) { throw "Missing function: $name" } + Invoke-Expression $function.Extent.Text +} +function Write-LogMessage { param($Message, $Level) } +function Assert-True { param($Value, $Message) if (-not $Value) { throw $Message } } +function Assert-Throws { + param([scriptblock]$Action) + $failed = $false + try { & $Action | Out-Null } catch { $failed = $true } + Assert-True $failed 'Expected version validation to fail.' +} + +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) +$null = New-Item -ItemType Directory -Path $temp +try { + $file = Join-Path $temp 'parameters.json' + $fixture = '{"LogicAppVersion":{"type":"String","value":""},"fixture":{"type":"Object","value":{"nested":[1,2],"expression":"@parameters(''fixture'')","text":"caf\u00e9"}}}' + [System.IO.File]::WriteAllText($file, $fixture) + $script:BundleVersion = (Get-Content -LiteralPath (Join-Path $bundle 'VERSION') -Raw -Encoding UTF8).Trim() + foreach ($version in @("$script:BundleVersion`n", "$script:BundleVersion`r`n", 'future-version')) { + $json = Get-VersionedParametersContent $file $version | ConvertFrom-Json + Assert-True ($json.LogicAppVersion.value -ceq $version.Trim()) 'Version was not derived from the supplied bundle.' + Assert-True (($json.fixture | ConvertTo-Json -Depth 100 -Compress) -ceq (($fixture | ConvertFrom-Json).fixture | ConvertTo-Json -Depth 100 -Compress)) 'Other parameters changed.' + Assert-True (([System.IO.File]::ReadAllText($file)) -ceq $fixture) 'Source parameters were modified.' + } + Assert-Throws { Get-VersionedParametersContent $file '' } + Assert-Throws { Get-VersionedParametersContent $file ' ' } + Assert-Throws { Get-VersionedParametersContent (Join-Path $temp 'missing.json') 'test' } + [System.IO.File]::WriteAllText($file, '{}') + Assert-Throws { Get-VersionedParametersContent $file 'test' } + [System.IO.File]::WriteAllText($file, $fixture) + + # Mock both external commands, so even the upload/read-back path stays offline. + function az { $global:LASTEXITCODE = 0; return 'local-test-placeholder' } + $script:uploaded = $null + $script:mismatch = $false + function Invoke-RestMethod { + param($Method, $Uri, $Headers, $Body, $ContentType) + if ($Method -eq 'PUT') { $script:uploaded = $Body | ConvertFrom-Json; return } + if ($script:mismatch) { return @{ LogicAppVersion = @{ value = 'wrong-version' } } } + return $script:uploaded + } + $script:ManagementApiBase = 'https://unused.invalid' + Assert-True (Deploy-Parameters 'test' 'test' 'test' $file) 'Matching read-back should pass.' + Assert-True ($script:uploaded.LogicAppVersion.value -ceq $script:BundleVersion) 'Upload used the template version.' + $script:mismatch = $true + Assert-True (-not (Deploy-Parameters 'test' 'test' 'test' $file)) 'Mismatched read-back should fail.' + + # These guards must fail before Azure CLI is consulted. + foreach ($mode in @('skip', 'missing', 'empty')) { + $args = @('-NoProfile', '-File', $scriptPath, '-SubscriptionId', 'test', '-ResourceGroup', 'test', '-LogicAppName', 'test', '-WorkflowsPath', $temp) + if ($mode -eq 'skip') { $args += '-SkipParameters' } + elseif ($mode -eq 'empty') { [System.IO.File]::WriteAllText((Join-Path $temp 'VERSION'), ' ') } + $output = & (Join-Path $PSHOME 'pwsh') @args 2>&1 | Out-String + Assert-True ($LASTEXITCODE -ne 0 -and $output.Contains('Version preparation failed')) "Preflight did not reject $mode." + } + Write-Output 'Version deployment checks passed' +} +finally { + Remove-Item -LiteralPath $file -ErrorAction SilentlyContinue + $versionFile = Join-Path $temp 'VERSION' + if (Test-Path -LiteralPath $versionFile) { Remove-Item -LiteralPath $versionFile } + Remove-Item -LiteralPath $temp +}