From 1318bd5015c01880e7d015caf5b903b0c56173b2 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Mon, 14 Sep 2026 13:49:20 -0700 Subject: [PATCH 1/9] Add `a365 network vnet` for linking an Azure VNet to Agent 365 The documented subnet-injection flow ends with Enable-SubnetInjection, which takes the id of the Power Platform environment to link. Agent 365 provisions a managed environment per tenant and does not publish its id, so admins cannot finish the flow. These subcommands replace that final step: the platform resolves the environment server-side and performs the link. The policy systemId read stays here rather than in the platform. It is a plain ARM GET against a resource the admin already owns, and doing it client-side with the admin's own az login avoids giving the platform a delegated ARM consent grant it otherwise has no need for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/commands/README.md | 4 + docs/commands/network.md | 105 +++++ .../Commands/NetworkCommand.cs | 241 ++++++++++ .../Constants/CommandNames.cs | 1 + .../Models/VNetModels.cs | 77 ++++ .../Program.cs | 12 + .../Services/ArmApiService.cs | 93 ++++ .../Services/IVNetLinkService.cs | 57 +++ .../Services/VNetLinkService.cs | 240 ++++++++++ .../Commands/NetworkCommandTests.cs | 217 +++++++++ .../Services/ArmApiServiceTests.cs | 146 ++++++ .../Services/VNetLinkServiceTests.cs | 427 ++++++++++++++++++ 13 files changed, 1621 insertions(+) create mode 100644 docs/commands/network.md create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aa369b..320e9957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `a365 network vnet link|unlink|status` — links an Azure virtual network to Agent 365 through a Power Platform NetworkInjection enterprise policy. Replaces `Enable-SubnetInjection` from the `Microsoft.PowerPlatform.EnterprisePolicies` module, which cannot be used because it requires the id of the Agent 365 managed environment and that id is not published. The CLI reads the policy's `systemId` from Azure with your existing `az login` and the platform performs the link against the environment it resolves for your tenant. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network.md](docs/commands/network.md). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/docs/commands/README.md b/docs/commands/README.md index 29153973..14f3260b 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -27,6 +27,10 @@ There is reference documentation for each command. | [develop-mcp list-servers](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-servers) | List MCP servers in a specific Dataverse environment. | | [develop-mcp publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-publish) | Publish an MCP server to a Dataverse environment. | | [develop-mcp unpublish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-unpublish) | Unpublish an MCP server from a Dataverse environment. | +| [network](network.md) | Configure tenant networking for Agent 365. | +| [network vnet link](network.md#link) | Link a NetworkInjection enterprise policy to your Agent 365 environment. | +| [network vnet unlink](network.md#unlink) | Remove the virtual network link from your Agent 365 environment. | +| [network vnet status](network.md#status) | Show whether a virtual network policy is linked to your Agent 365 environment. | | [publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/publish) | Update manifest.json ID values and publish the package. Configure federated identity and app role assignments. | | [query-entra](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra) | Query Microsoft Entra ID for agent information including scopes, permissions, and consent status. | | [query-entra blueprint-scopes](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra#query-entra-blueprint-scopes) | List configured scopes and consent status for the agent blueprint. | diff --git a/docs/commands/network.md b/docs/commands/network.md new file mode 100644 index 00000000..31123e53 --- /dev/null +++ b/docs/commands/network.md @@ -0,0 +1,105 @@ +# `a365 network vnet` + +Links an Azure virtual network to Agent 365 via a Power Platform **NetworkInjection enterprise +policy**, without needing the id of the Power Platform environment. + +## Why this command exists + +The documented subnet-injection flow +([Set up virtual network support](https://learn.microsoft.com/power-platform/admin/vnet-support-setup-configure)) +ends with `Enable-SubnetInjection` from the `Microsoft.PowerPlatform.EnterprisePolicies` module, +which takes an `-environmentId`. Agent 365 provisions a managed Power Platform environment for the +tenant and does not publish its id, so that final step cannot be run. + +`a365 network vnet` replaces only that last step. The CLI reads the policy's `systemId` from Azure +using your existing `az login`, then asks the Agent 365 platform to perform the link against the +environment it resolves for your tenant. + +Everything before the final step is unchanged — keep using the PowerShell module to create the +subnets, delegate them to `Microsoft.PowerPlatform/enterprisePolicies`, and create the policy with +`New-SubnetInjectionEnterprisePolicy`. + +## Prerequisites + +- **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects + anyone else. +- An active `az login` session in the same tenant. Used only to read the enterprise policy. +- A NetworkInjection enterprise policy already created by `New-SubnetInjectionEnterprisePolicy`, + with subnets delegated to `Microsoft.PowerPlatform/enterprisePolicies`. +- Public cloud only. Sovereign clouds are not supported. + +## Subcommands + +| Command | Description | +| --- | --- | +| `a365 network vnet link` | Link a NetworkInjection enterprise policy to the tenant's Agent 365 environment. | +| `a365 network vnet unlink` | Remove the virtual network link. | +| `a365 network vnet status` | Show the current link, or check a running operation. | + +### `link` + +```bash +a365 network vnet link --policy-arm-id [--swap] [--tenant-id ] [--wait] +``` + +| Option | Description | +| --- | --- | +| `--policy-arm-id`, `-p` | **Required.** ARM resource id of the policy, as returned by `New-SubnetInjectionEnterprisePolicy`. | +| `--swap` | Replace an existing link to a *different* policy. Without it, a different existing link is reported as a conflict instead of being silently replaced. | +| `--tenant-id` | Tenant to authenticate against for the Azure policy read. Defaults to the tenant of your current `az login`. | +| `--wait` | Poll until the operation settles instead of returning an operation id. | + +Linking the policy that is already linked is a no-op and succeeds without `--swap`. + +### `unlink` + +```bash +a365 network vnet unlink [--wait] +``` + +Unlink needs no policy id — the platform remembers which policy it linked. + +### `status` + +```bash +a365 network vnet status [--operation-id ] +``` + +Without `--operation-id`, reports the environment's current link. With one, reports that specific +operation. + +## Statuses and exit codes + +| Status | Meaning | +| --- | --- | +| `Linked` | A policy is linked; `Policy` names it. | +| `NotLinked` | No policy is linked. | +| `Running` / `NotStarted` | The operation is still in flight; `Operation` is the handle to poll. | +| `Failed` | The operation failed; `Reason` explains why. | + +Exit code is `1` on `Failed` or on any request error, and `0` otherwise — including a still-running +operation, which is a legitimate outcome when `--wait` is not passed. + +## Typical flow + +```bash +# 1. Create the policy with the PowerShell module (unchanged). +./SubnetInjection/NewSubnetInjectionEnterprisePolicy.ps1 ` + -subscription -resourceGroup -enterprisePolicyName ` + -enterprisePolicyLocation -virtualNetworkId -subnetName + +# 2. Link it — this replaces Enable-SubnetInjection. +a365 network vnet link --policy-arm-id /subscriptions//resourceGroups//providers/Microsoft.PowerPlatform/enterprisePolicies/ --wait + +# 3. Confirm. +a365 network vnet status +``` + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `Could not determine your Azure tenant` | No `az login` session. Run `az login`, or pass `--tenant-id`. | +| `403` from the platform | Caller is not a Global or Power Platform Administrator, or the CLI app lacks consent for the `AgentTools.VNet.*` scopes. | +| Conflict reported on `link` | A *different* policy is already linked. Re-run with `--swap`, or `unlink` first. | +| Policy read fails | The policy ARM id is wrong, or your `az login` identity cannot read it. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs new file mode 100644 index 00000000..9f18d8ee --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands; + +/// +/// Tenant network configuration for Agent 365. +/// +/// Subnet injection normally ends with Enable-SubnetInjection from the +/// Microsoft.PowerPlatform.EnterprisePolicies module, which needs the id of the Power Platform +/// environment being linked. Agent 365 does not publish that id, so these subcommands ask the +/// platform to perform the link against the environment it resolves for your tenant. +/// +public static class NetworkCommand +{ + private static readonly TimeSpan DefaultWaitTimeout = TimeSpan.FromMinutes(10); + + /// + /// Creates the network command and its vnet subcommand tree. + /// + public static Command CreateCommand( + ILogger logger, + IVNetLinkService vnetLinkService, + IAzureCliService azureCliService) + { + var networkCommand = new Command("network", "Configure tenant networking for Agent 365"); + + var vnetCommand = new Command( + "vnet", + "Link an Azure virtual network enterprise policy to your Agent 365 environment. " + + "Requires the Global Administrator or Power Platform Administrator role."); + + vnetCommand.AddCommand(CreateLinkSubcommand(logger, vnetLinkService, azureCliService)); + vnetCommand.AddCommand(CreateUnlinkSubcommand(logger, vnetLinkService)); + vnetCommand.AddCommand(CreateStatusSubcommand(logger, vnetLinkService)); + + networkCommand.AddCommand(vnetCommand); + return networkCommand; + } + + private static Command CreateLinkSubcommand( + ILogger logger, + IVNetLinkService vnetLinkService, + IAzureCliService azureCliService) + { + var command = new Command( + "link", + "Link a NetworkInjection enterprise policy to your Agent 365 environment. " + + "Create the policy first with New-SubnetInjectionEnterprisePolicy; this replaces the " + + "Enable-SubnetInjection step that requires an environment id."); + + var policyArmIdOption = new Option( + ["--policy-arm-id", "-p"], + "ARM resource id of the NetworkInjection enterprise policy, as returned by " + + "New-SubnetInjectionEnterprisePolicy") + { + IsRequired = true, + }; + + var swapOption = new Option( + "--swap", + "Replace an existing link to a different policy. Without this, an existing different " + + "link is reported as a conflict rather than silently replaced."); + + var tenantIdOption = new Option( + "--tenant-id", + "Tenant to authenticate against for the Azure policy read. Defaults to the tenant of " + + "your current az login."); + + var waitOption = new Option( + "--wait", + "Keep polling until the link settles, instead of returning an operation id."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + + command.AddOption(policyArmIdOption); + command.AddOption(swapOption); + command.AddOption(tenantIdOption); + command.AddOption(waitOption); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var policyArmId = context.ParseResult.GetValueForOption(policyArmIdOption)!; + var swap = context.ParseResult.GetValueForOption(swapOption); + var tenantId = context.ParseResult.GetValueForOption(tenantIdOption); + var wait = context.ParseResult.GetValueForOption(waitOption); + var ct = context.GetCancellationToken(); + + if (string.IsNullOrWhiteSpace(tenantId)) + { + var account = await azureCliService.GetCurrentAccountAsync(); + tenantId = account?.TenantId; + if (string.IsNullOrWhiteSpace(tenantId)) + { + logger.LogError("Could not determine your Azure tenant. Run 'az login', or pass --tenant-id."); + context.ExitCode = 1; + return; + } + } + + var result = await vnetLinkService.LinkAsync(policyArmId, swap, tenantId, ct); + context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Link", ct); + }); + + return command; + } + + private static Command CreateUnlinkSubcommand(ILogger logger, IVNetLinkService vnetLinkService) + { + var command = new Command( + "unlink", + "Remove the virtual network link from your Agent 365 environment."); + + var waitOption = new Option( + "--wait", + "Keep polling until the unlink settles, instead of returning an operation id."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + + command.AddOption(waitOption); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var wait = context.ParseResult.GetValueForOption(waitOption); + var ct = context.GetCancellationToken(); + + var result = await vnetLinkService.UnlinkAsync(ct); + context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Unlink", ct); + }); + + return command; + } + + private static Command CreateStatusSubcommand(ILogger logger, IVNetLinkService vnetLinkService) + { + var command = new Command( + "status", + "Show whether a virtual network policy is linked to your Agent 365 environment."); + + var operationIdOption = new Option( + "--operation-id", + "Operation handle returned by a link or unlink that was still running."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + + command.AddOption(operationIdOption); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var operationId = context.ParseResult.GetValueForOption(operationIdOption); + var ct = context.GetCancellationToken(); + + var status = await vnetLinkService.GetStatusAsync(operationId, ct); + if (status == null) + { + context.ExitCode = 1; + return; + } + + LogStatus(logger, status); + context.ExitCode = string.Equals(status.Status, "Failed", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + }); + + return command; + } + + /// + /// Renders the outcome of a link or unlink, optionally waiting for a running operation first, + /// and maps it to a process exit code. + /// + internal static async Task ReportAsync( + ILogger logger, + IVNetLinkService vnetLinkService, + VNetStatusResponse? result, + bool wait, + string operationLabel, + CancellationToken cancellationToken) + { + if (result == null) + { + return 1; + } + + if (wait && VNetLinkService.IsRunning(result.Status) && !string.IsNullOrWhiteSpace(result.OperationId)) + { + logger.LogInformation("{Operation} is running. Waiting for it to settle...", operationLabel); + result = await vnetLinkService.WaitForCompletionAsync(result.OperationId, DefaultWaitTimeout, cancellationToken); + + if (result == null) + { + return 1; + } + } + + LogStatus(logger, result); + + if (string.Equals(result.Status, "Failed", StringComparison.OrdinalIgnoreCase)) + { + return 1; + } + + if (VNetLinkService.IsRunning(result.Status)) + { + logger.LogInformation( + "{Operation} is still running. Check on it with: a365 network vnet status --operation-id {OperationId}", + operationLabel, + result.OperationId); + } + + return 0; + } + + private static void LogStatus(ILogger logger, VNetStatusResponse status) + { + logger.LogInformation("Status: {Status}", status.Status ?? "Unknown"); + + if (!string.IsNullOrWhiteSpace(status.PolicyArmId)) + { + logger.LogInformation("Policy: {PolicyArmId}", status.PolicyArmId); + } + + if (!string.IsNullOrWhiteSpace(status.OperationId)) + { + logger.LogInformation("Operation: {OperationId}", status.OperationId); + } + + if (!string.IsNullOrWhiteSpace(status.Reason)) + { + logger.LogWarning("Reason: {Reason}", status.Reason); + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/CommandNames.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/CommandNames.cs index 8c82ee86..7ea93af2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/CommandNames.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/CommandNames.cs @@ -18,4 +18,5 @@ public static class CommandNames public const string Develop = "develop"; public const string CreateInstance = "create-instance"; public const string Logs = "logs"; + public const string Network = "network"; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs new file mode 100644 index 00000000..b2701a6d --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Request body for linking a virtual network enterprise policy to the tenant's Agent 365 +/// Power Platform environment. +/// +public class VNetLinkRequest +{ + /// + /// The policy's properties.systemId, shaped + /// /regions/{region}/providers/Microsoft.PowerPlatform/enterprisePolicies/{guid}. + /// Resolved from the ARM policy id by the CLI, using the caller's own Azure session. + /// + [JsonPropertyName("policySystemId")] + public string? PolicySystemId { get; set; } + + /// + /// The policy's ARM resource id. Carried for display and audit only. + /// + [JsonPropertyName("policyArmId")] + public string? PolicyArmId { get; set; } + + /// + /// Whether an existing link to a different policy may be replaced. Mirrors the -Swap switch + /// on Enable-SubnetInjection. + /// + [JsonPropertyName("swap")] + public bool Swap { get; set; } +} + +/// +/// Status of the tenant's virtual network link, and the shape returned by link and unlink +/// once they settle. +/// +public class VNetStatusResponse +{ + /// + /// NotLinked, Running, Linked, Failed, or Unknown. + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// ARM id of the linked policy as reported by the platform. Null when nothing is linked. + /// + [JsonPropertyName("policyArmId")] + public string? PolicyArmId { get; set; } + + /// + /// Handle for an operation that is still running, or has recently settled. + /// + [JsonPropertyName("operationId")] + public string? OperationId { get; set; } + + /// + /// Failure reason, when the platform has one to report. + /// + [JsonPropertyName("reason")] + public string? Reason { get; set; } +} + +/// +/// Error body returned by the platform's virtual network endpoints. +/// +public class VNetErrorResponse +{ + /// + /// Human-readable error message. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 546dd535..566a313c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -185,6 +185,10 @@ await Task.WhenAll( var logsLogger = serviceProvider.GetRequiredService>(); var logRedactionService = serviceProvider.GetRequiredService(); rootCommand.AddCommand(LogsCommand.CreateCommand(logsLogger, logRedactionService)); + var networkLogger = serviceProvider.GetRequiredService().CreateLogger("network"); + var vnetLinkService = serviceProvider.GetRequiredService(); + var azureCliService = serviceProvider.GetRequiredService(); + rootCommand.AddCommand(NetworkCommand.CreateCommand(networkLogger, vnetLinkService, azureCliService)); // Build pipeline manually so we can skip UseTypoCorrections() ("Did you mean?" noise) // and UseParseErrorReporting() (full help dump on any parse error), replacing both @@ -376,6 +380,14 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(); services.AddSingleton(); + + // Reuses the environment the tooling service already resolved (env var, then config file), + // so the two never disagree about which Agent 365 deployment the CLI is talking to. + services.AddSingleton(provider => new VNetLinkService( + provider.GetRequiredService>(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService().Environment)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs index 150332dc..fee4ef14 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs @@ -25,6 +25,9 @@ public class ArmApiService : IDisposable private const string ResourceGroupApiVersion = "2021-04-01"; private const string AppServiceApiVersion = "2022-03-01"; + // Stable first: the module's own ARM templates deploy enterprise policies at 2020-10-30. + private static readonly string[] EnterprisePolicyApiVersions = ["2020-10-30", "2020-10-30-preview"]; + private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly IAuthenticationService _authService; @@ -243,4 +246,94 @@ private async Task EnsureArmHeadersAsync(string tenantId, CancellationToke } } + /// + /// Reads a Microsoft.PowerPlatform/enterprisePolicies resource and returns its + /// properties.systemId — the only identifier the Business App Platform accepts when + /// linking a policy to an environment. Shaped + /// /regions/{region}/providers/Microsoft.PowerPlatform/enterprisePolicies/{guid}, + /// which is not derivable from the ARM resource id. + /// + /// This read happens in the CLI, using the admin's own Azure session, so the Agent 365 + /// service never needs delegated ARM access. + /// + /// Returns null when the policy cannot be read or has no systemId; the message is logged. + /// + public virtual async Task GetEnterprisePolicySystemIdAsync( + string policyArmId, + string tenantId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(policyArmId)) + throw new ArgumentException("Policy ARM id is required.", nameof(policyArmId)); + + if (!await EnsureArmHeadersAsync(tenantId, ct)) + return null; + + // The stable and the preview version both ship on this RP and differ by tenant rollout, so + // a rejected api-version is a routine outcome rather than a failure worth surfacing. + foreach (var apiVersion in EnterprisePolicyApiVersions) + { + var url = $"{ArmBaseUrl}{policyArmId}?api-version={apiVersion}"; + _logger.LogDebug("ARM GET enterprise policy (api-version {ApiVersion})", apiVersion); + + try + { + using var response = await _retryHelper.ExecuteWithRetryAsync( + ct => _httpClient.GetAsync(url, ct), cancellationToken: ct); + + if (response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.LogDebug("ARM rejected api-version {ApiVersion}; trying the next one", apiVersion); + continue; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "Could not read enterprise policy {PolicyArmId}. Azure returned {StatusCode}. " + + "Check that the policy exists and that you have read access to it.", + policyArmId, + response.StatusCode); + return null; + } + + var body = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (!doc.RootElement.TryGetProperty("properties", out var properties) || + !properties.TryGetProperty("systemId", out var systemId)) + { + _logger.LogError( + "Enterprise policy {PolicyArmId} has no systemId. The policy may still be provisioning.", + policyArmId); + return null; + } + + var value = systemId.GetString(); + if (string.IsNullOrWhiteSpace(value)) + { + _logger.LogError( + "Enterprise policy {PolicyArmId} has an empty systemId. The policy may still be provisioning.", + policyArmId); + return null; + } + + _logger.LogDebug("Resolved enterprise policy systemId"); + return value; + } + catch (Exception ex) + { + if (NetworkHelper.IsConnectionResetByProxy(ex)) + _logger.LogWarning(NetworkHelper.ConnectionResetWarning); + else + _logger.LogError(ex, "Failed to read enterprise policy {PolicyArmId}", policyArmId); + return null; + } + } + + _logger.LogError( + "Azure rejected every supported enterprise policy api-version reading {PolicyArmId}.", + policyArmId); + return null; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs new file mode 100644 index 00000000..ff0037ee --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Links an Azure virtual network enterprise policy to the tenant's Agent 365 Power Platform +/// environment through the Agent 365 platform, which resolves that environment itself. +/// +public interface IVNetLinkService +{ + /// + /// Links a policy. Resolves the policy's systemId from ARM using the caller's Azure session, + /// then asks the platform to perform the link. + /// + /// ARM resource id of the NetworkInjection enterprise policy. + /// Whether an existing link to a different policy may be replaced. + /// Tenant to authenticate against for the ARM read. + /// Cancellation token. + /// The resulting status, or null when the operation could not be started. + Task LinkAsync( + string policyArmId, + bool swap, + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// Removes the current link. The platform supplies the policy identifier it stored at link time. + /// + /// Cancellation token. + /// The resulting status, or null when the operation could not be started. + Task UnlinkAsync(CancellationToken cancellationToken = default); + + /// + /// Reads the current link status, optionally resuming a specific operation handle. + /// + /// Handle returned by a link or unlink that was still running. + /// Cancellation token. + /// The current status, or null when it could not be read. + Task GetStatusAsync( + string? operationId = null, + CancellationToken cancellationToken = default); + + /// + /// Polls status until the operation reaches a terminal state or the timeout elapses. + /// + /// Handle of the running operation. + /// How long to keep polling. + /// Cancellation token. + /// The last status read, which may still be Running if the timeout elapsed. + Task WaitForCompletionAsync( + string operationId, + TimeSpan timeout, + CancellationToken cancellationToken = default); +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs new file mode 100644 index 00000000..f8e19e1d --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Calls the Agent 365 platform's /agents/vnet endpoints. +/// +/// The platform resolves the tenant's Power Platform environment itself, which is why this +/// replaces the final Enable-SubnetInjection step of the Microsoft.PowerPlatform.EnterprisePolicies +/// module: that cmdlet needs an environment id Agent 365 does not publish. +/// +/// The ARM read that turns a policy ARM id into the systemId the Business App Platform requires +/// happens here, in the CLI, under the admin's own Azure session. The platform therefore needs no +/// delegated ARM access of its own. +/// +public class VNetLinkService : IVNetLinkService +{ + private const string LinkPath = "/agents/vnet/link"; + private const string UnlinkPath = "/agents/vnet/unlink"; + private const string StatusPath = "/agents/vnet/status"; + + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); + + private readonly ILogger _logger; + private readonly IAuthenticationService _authService; + private readonly ArmApiService _armApiService; + private readonly string _environment; + private readonly HttpMessageHandler? _handler; + + public VNetLinkService( + ILogger logger, + IAuthenticationService authService, + ArmApiService armApiService, + string environment = "prod", + HttpMessageHandler? handler = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _armApiService = armApiService ?? throw new ArgumentNullException(nameof(armApiService)); + _environment = environment ?? "prod"; + _handler = handler; + } + + /// + public async Task LinkAsync( + string policyArmId, + bool swap, + string tenantId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(policyArmId)) + throw new ArgumentException("Policy ARM id is required.", nameof(policyArmId)); + + _logger.LogInformation("Reading enterprise policy from Azure..."); + var policySystemId = await _armApiService.GetEnterprisePolicySystemIdAsync(policyArmId, tenantId, cancellationToken); + if (string.IsNullOrWhiteSpace(policySystemId)) + { + _logger.LogError("Could not resolve the policy's systemId, so there is nothing to send to Agent 365."); + return null; + } + + var request = new VNetLinkRequest + { + PolicySystemId = policySystemId, + PolicyArmId = policyArmId, + Swap = swap, + }; + + _logger.LogInformation("Linking the policy to your Agent 365 environment..."); + return await SendAsync(HttpMethod.Post, LinkPath, request, "link virtual network", cancellationToken); + } + + /// + public async Task UnlinkAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Removing the virtual network link from your Agent 365 environment..."); + return await SendAsync(HttpMethod.Post, UnlinkPath, payload: null, "unlink virtual network", cancellationToken); + } + + /// + public async Task GetStatusAsync( + string? operationId = null, + CancellationToken cancellationToken = default) + { + var path = string.IsNullOrWhiteSpace(operationId) + ? StatusPath + : $"{StatusPath}?operationId={Uri.EscapeDataString(operationId)}"; + + return await SendAsync(HttpMethod.Get, path, payload: null, "read virtual network status", cancellationToken); + } + + /// + public async Task WaitForCompletionAsync( + string operationId, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(operationId)) + throw new ArgumentException("Operation id is required.", nameof(operationId)); + + // Wall clock, not summed sleeps: each status call costs real time, and a caller who asked + // for five minutes should not wait eight because the service was slow. + var stopwatch = Stopwatch.StartNew(); + VNetStatusResponse? last = null; + + while (true) + { + last = await GetStatusAsync(operationId, cancellationToken); + + if (last == null || !IsRunning(last.Status)) + return last; + + if (stopwatch.Elapsed + PollInterval >= timeout) + return last; + + _logger.LogInformation("Still running... ({Elapsed:0}s elapsed)", stopwatch.Elapsed.TotalSeconds); + await Task.Delay(PollInterval, cancellationToken); + } + } + + /// + /// True when the reported status means the operation has not settled yet. + /// + public static bool IsRunning(string? status) => + string.Equals(status, "Running", StringComparison.OrdinalIgnoreCase); + + private async Task SendAsync( + HttpMethod method, + string path, + object? payload, + string operationName, + CancellationToken cancellationToken) + { + var correlationId = HttpClientFactory.GenerateCorrelationId(); + var baseUrl = BuildBaseUrl(); + var url = $"{baseUrl}{path}"; + + try + { + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire an Agent 365 access token."); + return null; + } + + using var httpClient = HttpClientFactory.CreateAuthenticatedClient( + authToken, correlationId: correlationId, handler: _handler); + + using var request = new HttpRequestMessage(method, url); + if (payload != null) + { + var json = JsonSerializer.Serialize(payload); + request.Content = new StringContent(json, Encoding.UTF8); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + _logger.LogDebug("Request payload: {Payload}", json); + } + + _logger.LogDebug("{Method} {Url} (CorrelationId: {CorrelationId})", method, url, correlationId); + + using var response = await httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("Response {StatusCode}: {Body}", response.StatusCode, body); + + if (!response.IsSuccessStatusCode) + { + LogFailure(response.StatusCode, body, operationName, correlationId); + return null; + } + + // 200 and 202 share a shape as far as the CLI is concerned: a status, and an + // operationId when there is more to wait for. + return string.IsNullOrWhiteSpace(body) + ? new VNetStatusResponse() + : JsonSerializer.Deserialize(body); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (NetworkHelper.IsConnectionResetByProxy(ex)) + _logger.LogWarning(NetworkHelper.ConnectionResetWarning); + else + _logger.LogError(ex, "Failed to {Operation}. Correlation ID: {CorrelationId}", operationName, correlationId); + return null; + } + } + + private void LogFailure(HttpStatusCode statusCode, string body, string operationName, string correlationId) + { + string? message = null; + try + { + message = JsonSerializer.Deserialize(body)?.Error; + } + catch (JsonException) + { + // The platform always sends a typed error body, so a non-JSON body means something + // upstream of it answered. The status code is then the only usable signal. + } + + _logger.LogError( + "Failed to {Operation}. Status: {StatusCode}. {Message}", + operationName, + statusCode, + message ?? "No error detail was returned."); + + if (statusCode == HttpStatusCode.Forbidden) + { + _logger.LogError( + "This command requires the Global Administrator or Power Platform Administrator role, " + + "and a client application consented for AgentTools.VNet.Manage.All."); + } + + _logger.LogError("Correlation ID: {CorrelationId}", correlationId); + } + + private string BuildBaseUrl() + { + var discoverUrl = ConfigConstants.GetDiscoverEndpointUrl(_environment); + var uri = new Uri(discoverUrl); + return $"{uri.Scheme}://{uri.Authority}"; + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs new file mode 100644 index 00000000..68834a02 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using System.CommandLine; +using System.CommandLine.Parsing; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for the network command tree and its result reporting. +/// The subcommand handlers themselves are exercised through ReportAsync, which holds the +/// wait-and-exit-code logic; the handlers around it only parse options. +/// +public class NetworkCommandTests +{ + private const string OperationId = "op-abc"; + + private static Command CreateCommand(IVNetLinkService? vnet = null, IAzureCliService? azure = null) => + NetworkCommand.CreateCommand( + NullLogger.Instance, + vnet ?? Substitute.For(), + azure ?? Substitute.For()); + + // ──────────────────────────── Command tree shape ──────────────────────────── + + [Fact] + public void CreateCommand_ExposesTheVnetSubcommandTree() + { + var command = CreateCommand(); + + command.Name.Should().Be("network"); + + var vnet = command.Subcommands.Should().ContainSingle().Subject; + vnet.Name.Should().Be("vnet"); + vnet.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("link", "unlink", "status"); + } + + [Fact] + public void LinkSubcommand_RequiresPolicyArmIdAndOffersTheDocumentedOptions() + { + var link = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "link"); + + link.Options.Select(o => o.Name).Should() + .BeEquivalentTo("policy-arm-id", "swap", "tenant-id", "wait", "verbose"); + link.Options.Single(o => o.Name == "policy-arm-id").IsRequired.Should().BeTrue(); + link.Options.Single(o => o.Name == "swap").IsRequired.Should().BeFalse(); + } + + [Fact] + public void UnlinkSubcommand_TakesNoPolicyBecauseThePlatformStoredIt() + { + var unlink = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "unlink"); + + unlink.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "verbose"); + } + + [Fact] + public void StatusSubcommand_AcceptsAnOperationHandle() + { + var status = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "status"); + + status.Options.Select(o => o.Name).Should().BeEquivalentTo("operation-id", "verbose"); + } + + [Fact] + public void LinkSubcommand_ParsesItsOptions() + { + var command = CreateCommand(); + + var parsed = command.Parse("vnet link --policy-arm-id /p/1 --swap --tenant-id tid --wait"); + + parsed.Errors.Should().BeEmpty(); + } + + [Fact] + public void LinkSubcommand_WithoutPolicyArmId_FailsToParse() + { + var command = CreateCommand(); + + var parsed = command.Parse("vnet link"); + + parsed.Errors.Should().NotBeEmpty(because: "--policy-arm-id is required"); + } + + // ───────────────────────────────── ReportAsync ────────────────────────────── + + [Fact] + public async Task ReportAsync_WhenResultNull_ReturnsFailure() + { + var vnet = Substitute.For(); + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result: null, wait: true, "Link", CancellationToken.None); + + exitCode.Should().Be(1); + await vnet.DidNotReceive().WaitForCompletionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportAsync_WhenSettled_ReturnsSuccessWithoutWaiting() + { + var vnet = Substitute.For(); + var result = new VNetStatusResponse { Status = "Linked" }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + + exitCode.Should().Be(0); + await vnet.DidNotReceive().WaitForCompletionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportAsync_WhenFailed_ReturnsFailure() + { + var vnet = Substitute.For(); + var result = new VNetStatusResponse { Status = "Failed", Reason = "Region mismatch." }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: false, "Link", CancellationToken.None); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ReportAsync_WhenRunningAndNotWaiting_ReturnsSuccessAndLeavesTheHandle() + { + var vnet = Substitute.For(); + var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: false, "Link", CancellationToken.None); + + exitCode.Should().Be(0, because: "an accepted operation is not itself a failure"); + await vnet.DidNotReceive().WaitForCompletionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportAsync_WhenRunningAndWaiting_PollsThenReportsTheSettledStatus() + { + var vnet = Substitute.For(); + vnet.WaitForCompletionAsync(OperationId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); + var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + + exitCode.Should().Be(0); + await vnet.Received(1).WaitForCompletionAsync( + OperationId, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportAsync_WhenWaitSettlesAsFailed_ReturnsFailure() + { + var vnet = Substitute.For(); + vnet.WaitForCompletionAsync(OperationId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult( + new VNetStatusResponse { Status = "Failed", Reason = "Upstream rejected the link." })); + var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ReportAsync_WhenWaitCannotReadStatus_ReturnsFailure() + { + var vnet = Substitute.For(); + vnet.WaitForCompletionAsync(OperationId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ReportAsync_WhenRunningWithoutAHandle_DoesNotWait() + { + var vnet = Substitute.For(); + var result = new VNetStatusResponse { Status = "Running", OperationId = null }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + + exitCode.Should().Be(0); + await vnet.DidNotReceive().WaitForCompletionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportAsync_WhenUnlinkSettles_ReturnsSuccess() + { + var vnet = Substitute.For(); + var result = new VNetStatusResponse { Status = "NotLinked" }; + + var exitCode = await NetworkCommand.ReportAsync( + NullLogger.Instance, vnet, result, wait: true, "Unlink", CancellationToken.None); + + exitCode.Should().Be(0); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs index c003ff7d..a140d33e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs @@ -300,6 +300,152 @@ private static HttpResponseMessage BuildRoleAssignmentsResponse(string scope, st Content = new StringContent(body) }; } + + // ──────────────────────── GetEnterprisePolicySystemIdAsync ──────────────────────── + + private const string PolicyArmId = + "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.PowerPlatform/enterprisePolicies/policy-1"; + + private const string PolicySystemId = + "/regions/unitedstates/providers/Microsoft.PowerPlatform/enterprisePolicies/1b2c8a4e-0000-0000-0000-000000000000"; + + private static HttpResponseMessage PolicyResponse(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body) }; + + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_When200_ReturnsSystemId() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(PolicyResponse( + JsonSerializer.Serialize(new { properties = new { systemId = PolicySystemId } }))); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().Be(PolicySystemId, because: "the systemId is the only value BAP accepts for a link"); + } + + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_RequestsTheArmPolicyResource() + { + HttpRequestMessage? captured = null; + using var handler = new CapturingHttpMessageHandler(r => captured = r); + handler.QueueResponse(PolicyResponse( + JsonSerializer.Serialize(new { properties = new { systemId = PolicySystemId } }))); + var svc = CreateService(handler); + + await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + captured.Should().NotBeNull(); + captured!.Method.Should().Be(HttpMethod.Get); + captured.RequestUri!.ToString().Should().Be( + $"https://management.azure.com{PolicyArmId}?api-version=2020-10-30"); + } + + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_WhenStableApiVersionRejected_RetriesWithPreview() + { + var urls = new List(); + using var handler = new CapturingHttpMessageHandler(r => urls.Add(r.RequestUri!.ToString())); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("") }); + handler.QueueResponse(PolicyResponse( + JsonSerializer.Serialize(new { properties = new { systemId = PolicySystemId } }))); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().Be(PolicySystemId); + urls.Should().HaveCount(2); + urls[0].Should().EndWith("api-version=2020-10-30"); + urls[1].Should().EndWith("api-version=2020-10-30-preview"); + } + + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_WhenEveryApiVersionRejected_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("") }); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("") }); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().BeNull(because: "there is no api-version left to try"); + } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task GetEnterprisePolicySystemIdAsync_WhenNonSuccess_ReturnsNullWithoutRetrying(HttpStatusCode status) + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(status) { Content = new StringContent("") }); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().BeNull(because: "a rejected or missing policy is not an api-version problem"); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"properties\":{}}")] + [InlineData("{\"properties\":{\"systemId\":\"\"}}")] + [InlineData("{\"properties\":{\"systemId\":\" \"}}")] + public async Task GetEnterprisePolicySystemIdAsync_WhenSystemIdMissing_ReturnsNull(string body) + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(PolicyResponse(body)); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().BeNull(because: "a policy without a systemId is not yet usable for linking"); + } + + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_WhenHttpThrows_ReturnsNull() + { + using var handler = new ThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetEnterprisePolicySystemIdAsync_WhenPolicyArmIdBlank_Throws(string? policyArmId) + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var act = async () => await svc.GetEnterprisePolicySystemIdAsync(policyArmId!, TenantId); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_WhenTokenUnavailable_ReturnsNullWithoutCallingArm() + { + using var handler = new TestHttpMessageHandler(); + var auth = Substitute.For(); + auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(string.Empty)); + var svc = new ArmApiService(NullLogger.Instance, auth, handler, + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); + + var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0, because: "without a token there is nothing worth sending"); + } } /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs new file mode 100644 index 00000000..df602d60 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs @@ -0,0 +1,427 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for VNetLinkService. +/// Uses TestHttpMessageHandler / CapturingHttpMessageHandler (defined in GraphApiServiceTests.cs, +/// same assembly) to inject fake platform responses. +/// +public class VNetLinkServiceTests +{ + private const string TenantId = "tid"; + + private const string PolicyArmId = + "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.PowerPlatform/enterprisePolicies/policy-1"; + + private const string PolicySystemId = + "/regions/unitedstates/providers/Microsoft.PowerPlatform/enterprisePolicies/1b2c8a4e-0000-0000-0000-000000000000"; + + private const string OperationId = "op-abc"; + + private static IAuthenticationService FakeAuth(string token = "fake-a365-token") + { + var mock = Substitute.For(); + mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(token)); + return mock; + } + + private static ArmApiService FakeArm(string? systemId = PolicySystemId) + { + var arm = Substitute.For(); + arm.GetEnterprisePolicySystemIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(systemId)); + return arm; + } + + private static VNetLinkService CreateService( + HttpMessageHandler handler, + ArmApiService? arm = null, + IAuthenticationService? auth = null) => + new(NullLogger.Instance, auth ?? FakeAuth(), arm ?? FakeArm(), "prod", handler); + + private static HttpResponseMessage StatusResponse( + HttpStatusCode code, + string? status = null, + string? operationId = null, + string? policyArmId = null, + string? reason = null) => + new(code) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + status, + policyArmId, + operationId, + reason, + })), + }; + + // ──────────────────────────────── IsRunning ──────────────────────────────── + + [Theory] + [InlineData("Running", true)] + [InlineData("running", true)] + [InlineData("RUNNING", true)] + [InlineData("Linked", false)] + [InlineData("NotLinked", false)] + [InlineData("Failed", false)] + [InlineData("Unknown", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsRunning_ClassifiesStatus(string? status, bool expected) + { + VNetLinkService.IsRunning(status).Should().Be(expected); + } + + // ──────────────────────────────── LinkAsync ──────────────────────────────── + + [Fact] + public async Task LinkAsync_SendsResolvedSystemIdNotTheArmId() + { + HttpRequestMessage? captured = null; + string? body = null; + using var handler = new CapturingHttpMessageHandler(r => + { + captured = r; + body = r.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); + var svc = CreateService(handler); + + var result = await svc.LinkAsync(PolicyArmId, swap: true, TenantId); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Linked"); + result.PolicyArmId.Should().BeNull(); + result.OperationId.Should().BeNull(); + result.Reason.Should().BeNull(); + + captured.Should().NotBeNull(); + captured!.Method.Should().Be(HttpMethod.Post); + captured.RequestUri!.AbsolutePath.Should().Be("/agents/vnet/link"); + + body.Should().NotBeNull(); + using var doc = JsonDocument.Parse(body!); + doc.RootElement.GetProperty("policySystemId").GetString().Should().Be(PolicySystemId); + doc.RootElement.GetProperty("policyArmId").GetString().Should().Be(PolicyArmId); + doc.RootElement.GetProperty("swap").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task LinkAsync_WhenSwapNotRequested_SendsSwapFalse() + { + string? body = null; + using var handler = new CapturingHttpMessageHandler(r => + body = r.Content?.ReadAsStringAsync().GetAwaiter().GetResult()); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); + var svc = CreateService(handler); + + await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + using var doc = JsonDocument.Parse(body!); + doc.RootElement.GetProperty("swap").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task LinkAsync_When202_ReturnsRunningWithOperationId() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.Accepted, "Running", OperationId)); + var svc = CreateService(handler); + + var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Running"); + result.OperationId.Should().Be(OperationId); + result.PolicyArmId.Should().BeNull(); + result.Reason.Should().BeNull(); + } + + [Fact] + public async Task LinkAsync_WhenSystemIdCannotBeResolved_DoesNotCallThePlatform() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, FakeArm(systemId: null)); + + var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0, because: "there is no systemId to send"); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Conflict)] + [InlineData(HttpStatusCode.BadGateway)] + public async Task LinkAsync_WhenPlatformFails_ReturnsNull(HttpStatusCode status) + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(status) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "nope" })), + }); + var svc = CreateService(handler); + + var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + result.Should().BeNull(); + } + + [Fact] + public async Task LinkAsync_WhenErrorBodyIsNotJson_StillReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("gateway"), + }); + var svc = CreateService(handler); + + var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + result.Should().BeNull(because: "a non-JSON body means something upstream of the platform answered"); + } + + [Fact] + public async Task LinkAsync_WhenTokenUnavailable_ReturnsNullWithoutCallingThePlatform() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, auth: FakeAuth(string.Empty)); + + var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task LinkAsync_WhenHttpThrows_ReturnsNull() + { + using var handler = new ThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task LinkAsync_WhenPolicyArmIdBlank_Throws(string? policyArmId) + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var act = async () => await svc.LinkAsync(policyArmId!, swap: false, TenantId); + + await act.Should().ThrowAsync(); + } + + // ─────────────────────────────── UnlinkAsync ─────────────────────────────── + + [Fact] + public async Task UnlinkAsync_PostsToUnlinkWithNoBody() + { + HttpRequestMessage? captured = null; + using var handler = new CapturingHttpMessageHandler(r => captured = r); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotLinked")); + var svc = CreateService(handler); + + var result = await svc.UnlinkAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be("NotLinked"); + result.PolicyArmId.Should().BeNull(); + result.OperationId.Should().BeNull(); + result.Reason.Should().BeNull(); + + captured!.Method.Should().Be(HttpMethod.Post); + captured.RequestUri!.AbsolutePath.Should().Be("/agents/vnet/unlink"); + captured.Content.Should().BeNull(because: "the platform supplies the stored policy itself"); + } + + [Fact] + public async Task UnlinkAsync_WhenPlatformFails_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "no stored policy" })), + }); + var svc = CreateService(handler); + + var result = await svc.UnlinkAsync(); + + result.Should().BeNull(); + } + + // ────────────────────────────── GetStatusAsync ───────────────────────────── + + [Fact] + public async Task GetStatusAsync_WithoutOperationId_OmitsTheQueryString() + { + HttpRequestMessage? captured = null; + using var handler = new CapturingHttpMessageHandler(r => captured = r); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", policyArmId: PolicyArmId)); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Linked"); + result.PolicyArmId.Should().Be(PolicyArmId); + result.OperationId.Should().BeNull(); + result.Reason.Should().BeNull(); + + captured!.Method.Should().Be(HttpMethod.Get); + captured.RequestUri!.AbsolutePath.Should().Be("/agents/vnet/status"); + captured.RequestUri.Query.Should().BeEmpty(); + } + + [Fact] + public async Task GetStatusAsync_WithOperationId_EscapesItIntoTheQueryString() + { + HttpRequestMessage? captured = null; + using var handler = new CapturingHttpMessageHandler(r => captured = r); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Running", "a b/c")); + var svc = CreateService(handler); + + await svc.GetStatusAsync("a b/c"); + + captured!.RequestUri!.Query.Should().Be("?operationId=a%20b%2Fc"); + } + + [Fact] + public async Task GetStatusAsync_WhenBodyEmpty_ReturnsEmptyStatus() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().BeNull(); + result.PolicyArmId.Should().BeNull(); + result.OperationId.Should().BeNull(); + result.Reason.Should().BeNull(); + } + + [Fact] + public async Task GetStatusAsync_SurfacesTheFailureReason() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Failed", OperationId, reason: "Region mismatch.")); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(OperationId); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Failed"); + result.Reason.Should().Be("Region mismatch."); + result.OperationId.Should().Be(OperationId); + result.PolicyArmId.Should().BeNull(); + } + + // ───────────────────────── WaitForCompletionAsync ────────────────────────── + + [Fact] + public async Task WaitForCompletionAsync_ReturnsAsSoonAsTheOperationSettles() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", OperationId)); + var svc = CreateService(handler); + + var result = await svc.WaitForCompletionAsync(OperationId, TimeSpan.FromMinutes(5)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Linked"); + handler.RequestCount.Should().Be(1, because: "a settled operation needs no second poll"); + } + + [Fact] + public async Task WaitForCompletionAsync_WhenStillRunningAndBudgetExhausted_ReturnsRunning() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Running", OperationId)); + var svc = CreateService(handler); + + // A zero budget cannot fit another poll interval, so the first read is also the last. + var result = await svc.WaitForCompletionAsync(OperationId, TimeSpan.Zero); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Running"); + result.OperationId.Should().Be(OperationId); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForCompletionAsync_WhenStatusCannotBeRead_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "upstream" })), + }); + var svc = CreateService(handler); + + var result = await svc.WaitForCompletionAsync(OperationId, TimeSpan.FromMinutes(5)); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(1, because: "an unreadable status is terminal for the wait"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task WaitForCompletionAsync_WhenOperationIdBlank_Throws(string? operationId) + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var act = async () => await svc.WaitForCompletionAsync(operationId!, TimeSpan.FromMinutes(5)); + + await act.Should().ThrowAsync(); + } + + // ───────────────────────────── Constructor guards ────────────────────────── + + [Fact] + public void Constructor_WhenLoggerNull_Throws() + { + var act = () => new VNetLinkService(null!, FakeAuth(), FakeArm()); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenAuthServiceNull_Throws() + { + var act = () => new VNetLinkService(NullLogger.Instance, null!, FakeArm()); + act.Should().Throw(); + } + + [Fact] + public void Constructor_WhenArmApiServiceNull_Throws() + { + var act = () => new VNetLinkService(NullLogger.Instance, FakeAuth(), null!); + act.Should().Throw(); + } +} From 72f3ab47c17a4d699f51896528f15dc1b0bd4bf1 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Tue, 15 Sep 2026 12:04:55 -0700 Subject: [PATCH 2/9] Add a365 network gsa enable|disable|status Global Secure Access is a per-environment Power Platform setting, and Agent 365 does not publish the id of the managed environment it provisions, so the admin surfaces that take an environment id cannot reach it. The platform resolves the environment and applies the change; these commands carry no environment identifier at all. Two things that are not obvious from the diff: Power Platform applies the change asynchronously but issues no operation id for it, so unlike vnet there is no handle to poll. The CLI converges by re-reading the setting, which is why status takes no --operation-id. NotConfigured is reported distinctly from Disabled. A tenant that has never set the value has not turned it off, and the distinction changes what an admin should do next. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/commands/README.md | 3 + docs/commands/network-gsa.md | 80 +++++ .../Commands/NetworkCommand.cs | 133 +++++++- .../Models/GsaModels.cs | 47 +++ .../Program.cs | 7 +- .../Services/GsaService.cs | 202 +++++++++++ .../Services/IGsaService.cs | 40 +++ .../Commands/NetworkCommandTests.cs | 164 ++++++++- .../Services/GsaServiceTests.cs | 320 ++++++++++++++++++ 10 files changed, 991 insertions(+), 6 deletions(-) create mode 100644 docs/commands/network-gsa.md create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 320e9957..921f3f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. Global Secure Access is a per-environment Power Platform setting, and the Agent 365 managed environment's id is not published, so the platform resolves that environment and applies the change on your behalf. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md). - `a365 network vnet link|unlink|status` — links an Azure virtual network to Agent 365 through a Power Platform NetworkInjection enterprise policy. Replaces `Enable-SubnetInjection` from the `Microsoft.PowerPlatform.EnterprisePolicies` module, which cannot be used because it requires the id of the Agent 365 managed environment and that id is not published. The CLI reads the policy's `systemId` from Azure with your existing `az login` and the platform performs the link against the environment it resolves for your tenant. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network.md](docs/commands/network.md). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. diff --git a/docs/commands/README.md b/docs/commands/README.md index 14f3260b..8ec8f708 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -31,6 +31,9 @@ There is reference documentation for each command. | [network vnet link](network.md#link) | Link a NetworkInjection enterprise policy to your Agent 365 environment. | | [network vnet unlink](network.md#unlink) | Remove the virtual network link from your Agent 365 environment. | | [network vnet status](network.md#status) | Show whether a virtual network policy is linked to your Agent 365 environment. | +| [network gsa enable](network-gsa.md#enable-and-disable) | Turn Global Secure Access on for your Agent 365 environment. | +| [network gsa disable](network-gsa.md#enable-and-disable) | Turn Global Secure Access off for your Agent 365 environment. | +| [network gsa status](network-gsa.md#status) | Show whether Global Secure Access is on for your Agent 365 environment. | | [publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/publish) | Update manifest.json ID values and publish the package. Configure federated identity and app role assignments. | | [query-entra](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra) | Query Microsoft Entra ID for agent information including scopes, permissions, and consent status. | | [query-entra blueprint-scopes](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra#query-entra-blueprint-scopes) | List configured scopes and consent status for the agent blueprint. | diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md new file mode 100644 index 00000000..7dafb26b --- /dev/null +++ b/docs/commands/network-gsa.md @@ -0,0 +1,80 @@ +# `a365 network gsa` + +Turns **Global Secure Access** on or off for the tenant's Agent 365 Power Platform environment, +without needing the id of that environment. + +## Why this command exists + +Global Secure Access is a per-environment Power Platform setting. Agent 365 provisions a managed +environment for the tenant and does not publish its id, so the setting cannot be reached through +the Power Platform admin surfaces that take an environment id. These subcommands ask the Agent 365 +platform to apply the change against the environment it resolves for your tenant. + +## Prerequisites + +- **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects + anyone else. +- Public cloud only. Sovereign clouds are not supported. + +No `az login` is needed — unlike `a365 network vnet`, nothing is read from Azure. + +## Subcommands + +| Command | Description | +| --- | --- | +| `a365 network gsa enable` | Turn Global Secure Access on. | +| `a365 network gsa disable` | Turn Global Secure Access off. | +| `a365 network gsa status` | Show whether Global Secure Access is on. | + +### `enable` and `disable` + +```bash +a365 network gsa enable [--wait] +a365 network gsa disable [--wait] +``` + +| Option | Description | +| --- | --- | +| `--wait` | Keep polling until the change appears on the environment, instead of returning while it is still being applied. | + +Requesting the value the environment already holds is a no-op and succeeds. + +### `status` + +```bash +a365 network gsa status +``` + +There is no operation handle to pass. Power Platform applies the change asynchronously but issues +no operation id for it, so the CLI reports progress by re-reading the setting rather than by +polling a handle. + +## Statuses and exit codes + +| Status | Meaning | +| --- | --- | +| `Enabled` | Global Secure Access is on. | +| `Disabled` | Global Secure Access is off. | +| `NotConfigured` | The tenant has never set the value. This is **not** the same as `Disabled`. | + +A change that has been accepted but has not yet surfaced is reported as still being applied, with +the status still showing the value it has not yet displaced. + +Exit code is `1` on any request error, and `0` otherwise — including a change that is still being +applied, which is a legitimate outcome when `--wait` is not passed. + +## Typical flow + +```bash +a365 network gsa enable --wait +a365 network gsa status +``` + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `403` from the platform | Caller is not a Global or Power Platform Administrator, or the CLI app lacks consent for the `AgentTools.Gsa.*` scopes. | +| `409`, reporting a governing policy | A Power Platform policy owns this setting. Change it through that policy; the environment-level value is ignored while the policy applies. | +| `404`, reporting no environment | The tenant has no Agent 365 environment yet. | +| Status stays `NotConfigured` after `disable` | Read it again — the change is applied asynchronously and `--wait` is the way to block on it. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs index 9f18d8ee..2bca25a4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -27,7 +27,8 @@ public static class NetworkCommand public static Command CreateCommand( ILogger logger, IVNetLinkService vnetLinkService, - IAzureCliService azureCliService) + IAzureCliService azureCliService, + IGsaService gsaService) { var networkCommand = new Command("network", "Configure tenant networking for Agent 365"); @@ -40,7 +41,17 @@ public static Command CreateCommand( vnetCommand.AddCommand(CreateUnlinkSubcommand(logger, vnetLinkService)); vnetCommand.AddCommand(CreateStatusSubcommand(logger, vnetLinkService)); + var gsaCommand = new Command( + "gsa", + "Turn Global Secure Access on or off for your Agent 365 environment. " + + "Requires the Global Administrator or Power Platform Administrator role."); + + gsaCommand.AddCommand(CreateGsaSetSubcommand(logger, gsaService, enabled: true)); + gsaCommand.AddCommand(CreateGsaSetSubcommand(logger, gsaService, enabled: false)); + gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService)); + networkCommand.AddCommand(vnetCommand); + networkCommand.AddCommand(gsaCommand); return networkCommand; } @@ -219,6 +230,126 @@ internal static async Task ReportAsync( return 0; } + /// + /// Creates the gsa enable or disable subcommand. The two differ only in the value they send + /// and the words they use, so they share one builder. + /// + private static Command CreateGsaSetSubcommand(ILogger logger, IGsaService gsaService, bool enabled) + { + var verb = enabled ? "enable" : "disable"; + var command = new Command( + verb, + $"Turn Global Secure Access {(enabled ? "on" : "off")} for your Agent 365 environment."); + + var waitOption = new Option( + "--wait", + "Keep polling until the change appears on the environment, instead of returning while " + + "it is still being applied."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + + command.AddOption(waitOption); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var wait = context.ParseResult.GetValueForOption(waitOption); + var ct = context.GetCancellationToken(); + + var result = await gsaService.SetAsync(enabled, ct); + context.ExitCode = await ReportGsaAsync(logger, gsaService, result, wait, enabled, ct); + }); + + return command; + } + + private static Command CreateGsaStatusSubcommand(ILogger logger, IGsaService gsaService) + { + var command = new Command( + "status", + "Show whether Global Secure Access is on for your Agent 365 environment."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var ct = context.GetCancellationToken(); + + var status = await gsaService.GetStatusAsync(ct); + if (status == null) + { + context.ExitCode = 1; + return; + } + + LogGsaStatus(logger, status); + context.ExitCode = 0; + }); + + return command; + } + + /// + /// Renders the outcome of a Global Secure Access change, optionally waiting for it to appear + /// first, and maps it to a process exit code. + /// + internal static async Task ReportGsaAsync( + ILogger logger, + IGsaService gsaService, + GsaStatusResponse? result, + bool wait, + bool enabled, + CancellationToken cancellationToken) + { + if (result == null) + { + return 1; + } + + var expectedStatus = enabled ? "Enabled" : "Disabled"; + + if (wait && result.Pending) + { + logger.LogInformation("The change is still being applied. Waiting for it to appear..."); + result = await gsaService.WaitForStatusAsync(expectedStatus, DefaultWaitTimeout, cancellationToken); + + if (result == null) + { + return 1; + } + } + + LogGsaStatus(logger, result); + + // Still pending is not a failure. The platform accepted the change and the environment + // will catch up; reporting non-zero here would break scripts that chain on success. + if (result.Pending) + { + logger.LogInformation( + "Still being applied. Check on it with: a365 network gsa status"); + } + + return 0; + } + + private static void LogGsaStatus(ILogger logger, GsaStatusResponse status) + { + logger.LogInformation("Global Secure Access: {Status}", status.Status ?? "Unknown"); + + if (string.Equals(status.Status, "NotConfigured", StringComparison.OrdinalIgnoreCase)) + { + // Worth spelling out: a tenant that has never set this is not the same as one that + // turned it off, and the distinction changes what an admin should do next. + logger.LogInformation("This tenant has never set Global Secure Access, so no value is stored."); + } + + if (!string.IsNullOrWhiteSpace(status.Reason)) + { + logger.LogWarning("Reason: {Reason}", status.Reason); + } + } + private static void LogStatus(ILogger logger, VNetStatusResponse status) { logger.LogInformation("Status: {Status}", status.Status ?? "Unknown"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs new file mode 100644 index 00000000..f06cbf19 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Status of Global Secure Access on the tenant's Agent 365 Power Platform environment, and the +/// shape returned by enable and disable. +/// +public class GsaStatusResponse +{ + /// + /// Enabled, Disabled, or NotConfigured. + /// + /// NotConfigured is not the same as Disabled: it means the tenant has never set the value. + /// The platform keeps the two apart, so the CLI does too. + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// True when a change was accepted but has not yet appeared on the environment. The + /// accompanying is then the value it has not yet displaced. + /// + [JsonPropertyName("pending")] + public bool Pending { get; set; } + + /// + /// Explanation the platform has to offer, when there is one. + /// + [JsonPropertyName("reason")] + public string? Reason { get; set; } +} + +/// +/// Error body returned by the platform's Global Secure Access endpoints. +/// +public class GsaErrorResponse +{ + /// + /// Human-readable error message. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 566a313c..d5fecab0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -188,7 +188,8 @@ await Task.WhenAll( var networkLogger = serviceProvider.GetRequiredService().CreateLogger("network"); var vnetLinkService = serviceProvider.GetRequiredService(); var azureCliService = serviceProvider.GetRequiredService(); - rootCommand.AddCommand(NetworkCommand.CreateCommand(networkLogger, vnetLinkService, azureCliService)); + var gsaService = serviceProvider.GetRequiredService(); + rootCommand.AddCommand(NetworkCommand.CreateCommand(networkLogger, vnetLinkService, azureCliService, gsaService)); // Build pipeline manually so we can skip UseTypoCorrections() ("Did you mean?" noise) // and UseParseErrorReporting() (full help dump on any parse error), replacing both @@ -388,6 +389,10 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService().Environment)); + services.AddSingleton(provider => new GsaService( + provider.GetRequiredService>(), + provider.GetRequiredService(), + provider.GetRequiredService().Environment)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs new file mode 100644 index 00000000..0de886d3 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Calls the Agent 365 platform's /agents/gsa endpoints. +/// +/// The setting lives on the tenant's Power Platform environment, whose id Agent 365 does not +/// publish. The platform resolves that environment itself, so these calls carry no environment +/// identifier at all. +/// +public class GsaService : IGsaService +{ + private const string EnablePath = "/agents/gsa/enable"; + private const string DisablePath = "/agents/gsa/disable"; + private const string StatusPath = "/agents/gsa/status"; + + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); + + private readonly ILogger _logger; + private readonly IAuthenticationService _authService; + private readonly string _environment; + private readonly HttpMessageHandler? _handler; + + public GsaService( + ILogger logger, + IAuthenticationService authService, + string environment = "prod", + HttpMessageHandler? handler = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _environment = environment ?? "prod"; + _handler = handler; + } + + /// + public async Task SetAsync(bool enabled, CancellationToken cancellationToken = default) + { + var path = enabled ? EnablePath : DisablePath; + var operationName = enabled ? "enable Global Secure Access" : "disable Global Secure Access"; + + _logger.LogInformation( + "{Action} Global Secure Access on your Agent 365 environment...", + enabled ? "Enabling" : "Disabling"); + + return await SendAsync(HttpMethod.Post, path, operationName, cancellationToken); + } + + /// + public async Task GetStatusAsync(CancellationToken cancellationToken = default) => + await SendAsync(HttpMethod.Get, StatusPath, "read Global Secure Access status", cancellationToken); + + /// + public async Task WaitForStatusAsync( + string expectedStatus, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(expectedStatus)) + throw new ArgumentException("Expected status is required.", nameof(expectedStatus)); + + // Wall clock, not summed sleeps: each status call costs real time, and a caller who asked + // for five minutes should not wait eight because the service was slow. + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + var last = await GetStatusAsync(cancellationToken); + + if (last == null || string.Equals(last.Status, expectedStatus, StringComparison.OrdinalIgnoreCase)) + return last; + + if (stopwatch.Elapsed + PollInterval >= timeout) + return last; + + _logger.LogInformation("Still applying... ({Elapsed:0}s elapsed)", stopwatch.Elapsed.TotalSeconds); + await Task.Delay(PollInterval, cancellationToken); + } + } + + private async Task SendAsync( + HttpMethod method, + string path, + string operationName, + CancellationToken cancellationToken) + { + var correlationId = HttpClientFactory.GenerateCorrelationId(); + var baseUrl = BuildBaseUrl(); + var url = $"{baseUrl}{path}"; + + try + { + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire an Agent 365 access token."); + return null; + } + + using var httpClient = HttpClientFactory.CreateAuthenticatedClient( + authToken, correlationId: correlationId, handler: _handler); + + using var request = new HttpRequestMessage(method, url); + + // The platform derives everything it needs from the token, so enable and disable are + // distinguished by route rather than by a body. + if (method == HttpMethod.Post) + { + request.Content = new StringContent(string.Empty, Encoding.UTF8); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + + _logger.LogDebug("{Method} {Url} (CorrelationId: {CorrelationId})", method, url, correlationId); + + using var response = await httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("Response {StatusCode}: {Body}", response.StatusCode, body); + + if (!response.IsSuccessStatusCode) + { + LogFailure(response.StatusCode, body, operationName, correlationId); + return null; + } + + // 200 and 202 share a shape as far as the CLI is concerned: a status, plus a pending + // flag when the change has not surfaced yet. + return string.IsNullOrWhiteSpace(body) + ? new GsaStatusResponse() + : JsonSerializer.Deserialize(body); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (NetworkHelper.IsConnectionResetByProxy(ex)) + _logger.LogWarning(NetworkHelper.ConnectionResetWarning); + else + _logger.LogError(ex, "Failed to {Operation}. Correlation ID: {CorrelationId}", operationName, correlationId); + return null; + } + } + + private void LogFailure(HttpStatusCode statusCode, string body, string operationName, string correlationId) + { + string? message = null; + try + { + message = JsonSerializer.Deserialize(body)?.Error; + } + catch (JsonException) + { + // The platform always sends a typed error body, so a non-JSON body means something + // upstream of it answered. The status code is then the only usable signal. + } + + _logger.LogError( + "Failed to {Operation}. Status: {StatusCode}. {Message}", + operationName, + statusCode, + message ?? "No error detail was returned."); + + if (statusCode == HttpStatusCode.Forbidden) + { + _logger.LogError( + "This command requires the Global Administrator or Power Platform Administrator role, " + + "and a client application consented for AgentTools.Gsa.Manage.All."); + } + + if (statusCode == HttpStatusCode.Conflict) + { + // Retrying cannot fix this one, so say why rather than letting it look transient. + _logger.LogError( + "A Power Platform policy governs this setting. Change it through that policy instead."); + } + + _logger.LogError("Correlation ID: {CorrelationId}", correlationId); + } + + private string BuildBaseUrl() + { + var discoverUrl = ConfigConstants.GetDiscoverEndpointUrl(_environment); + var uri = new Uri(discoverUrl); + return $"{uri.Scheme}://{uri.Authority}"; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs new file mode 100644 index 00000000..76ebcf50 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Turns Global Secure Access on and off for the tenant's Agent 365 Power Platform environment +/// through the Agent 365 platform, which resolves that environment itself. +/// +public interface IGsaService +{ + /// + /// Sets Global Secure Access to the requested value. + /// + /// The value to apply. + /// Cancellation token. + /// The resulting status, or null when the change could not be requested. + Task SetAsync(bool enabled, CancellationToken cancellationToken = default); + + /// + /// Reads the current Global Secure Access setting. + /// + /// Cancellation token. + /// The current status, or null when it could not be read. + Task GetStatusAsync(CancellationToken cancellationToken = default); + + /// + /// Polls status until the environment reports the requested value or the timeout elapses. + /// + /// The status being waited for, Enabled or Disabled. + /// How long to keep polling. + /// Cancellation token. + /// The last status read, which may still differ if the timeout elapsed. + Task WaitForStatusAsync( + string expectedStatus, + TimeSpan timeout, + CancellationToken cancellationToken = default); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs index 68834a02..322a0c05 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -22,11 +22,15 @@ public class NetworkCommandTests { private const string OperationId = "op-abc"; - private static Command CreateCommand(IVNetLinkService? vnet = null, IAzureCliService? azure = null) => + private static Command CreateCommand( + IVNetLinkService? vnet = null, + IAzureCliService? azure = null, + IGsaService? gsa = null) => NetworkCommand.CreateCommand( NullLogger.Instance, vnet ?? Substitute.For(), - azure ?? Substitute.For()); + azure ?? Substitute.For(), + gsa ?? Substitute.For()); // ──────────────────────────── Command tree shape ──────────────────────────── @@ -37,11 +41,21 @@ public void CreateCommand_ExposesTheVnetSubcommandTree() command.Name.Should().Be("network"); - var vnet = command.Subcommands.Should().ContainSingle().Subject; - vnet.Name.Should().Be("vnet"); + var vnet = command.Subcommands.Single(c => c.Name == "vnet"); vnet.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("link", "unlink", "status"); } + [Fact] + public void CreateCommand_ExposesTheGsaSubcommandTree() + { + var command = CreateCommand(); + + command.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("vnet", "gsa"); + + var gsa = command.Subcommands.Single(c => c.Name == "gsa"); + gsa.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("enable", "disable", "status"); + } + [Fact] public void LinkSubcommand_RequiresPolicyArmIdAndOffersTheDocumentedOptions() { @@ -214,4 +228,146 @@ public async Task ReportAsync_WhenUnlinkSettles_ReturnsSuccess() exitCode.Should().Be(0); } + + // ─────────────────────────── GSA subcommand shape ─────────────────────────── + + [Theory] + [InlineData("enable")] + [InlineData("disable")] + public void GsaSetSubcommands_OfferWaitAndVerboseOnly(string name) + { + var gsa = CreateCommand().Subcommands.Single(c => c.Name == "gsa"); + + var subcommand = gsa.Subcommands.Single(c => c.Name == name); + + subcommand.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "verbose"); + } + + [Fact] + public void GsaStatusSubcommand_TakesNoOperationHandle() + { + var gsa = CreateCommand().Subcommands.Single(c => c.Name == "gsa"); + + var status = gsa.Subcommands.Single(c => c.Name == "status"); + + // GSA converges on re-read rather than issuing a handle, so there is nothing to look up. + status.Options.Select(o => o.Name).Should().BeEquivalentTo(new[] { "verbose" }); + } + + [Fact] + public void GsaEnableSubcommand_ParsesItsOptions() + { + var parsed = CreateCommand().Parse("gsa enable --wait"); + + parsed.Errors.Should().BeEmpty(); + } + + // ──────────────────────────────── ReportGsaAsync ──────────────────────────── + + [Fact] + public async Task ReportGsaAsync_WhenResultNull_ReturnsFailure() + { + var gsa = Substitute.For(); + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result: null, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(1); + await gsa.DidNotReceive().WaitForStatusAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportGsaAsync_WhenSettled_ReturnsSuccessWithoutWaiting() + { + var gsa = Substitute.For(); + var result = new GsaStatusResponse { Status = "Enabled", Pending = false }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(0); + await gsa.DidNotReceive().WaitForStatusAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportGsaAsync_WhenPendingAndNotWaiting_ReturnsSuccess() + { + var gsa = Substitute.For(); + var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: false, enabled: true, CancellationToken.None); + + exitCode.Should().Be(0, because: "an accepted change that has not surfaced yet is not a failure"); + await gsa.DidNotReceive().WaitForStatusAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(true, "Enabled")] + [InlineData(false, "Disabled")] + public async Task ReportGsaAsync_WhenPendingAndWaiting_PollsForTheRequestedStatus( + bool enabled, string expectedStatus) + { + var gsa = Substitute.For(); + gsa.WaitForStatusAsync(expectedStatus, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult( + new GsaStatusResponse { Status = expectedStatus, Pending = false })); + var result = new GsaStatusResponse { Status = "NotConfigured", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled, CancellationToken.None); + + exitCode.Should().Be(0); + await gsa.Received(1).WaitForStatusAsync( + expectedStatus, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportGsaAsync_WhenWaitCannotReadStatus_ReturnsFailure() + { + var gsa = Substitute.For(); + gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ReportGsaAsync_WhenStillPendingAfterWaiting_ReturnsSuccess() + { + var gsa = Substitute.For(); + gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult( + new GsaStatusResponse { Status = "Disabled", Pending = true })); + var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(0, because: "the platform accepted the change; the environment is catching up"); + } + + [Fact] + public async Task ReportGsaAsync_WithAReasonOnASettledResult_StillSucceeds() + { + var gsa = Substitute.For(); + var result = new GsaStatusResponse + { + Status = "NotConfigured", + Pending = false, + Reason = "This tenant has no Agent 365 environment yet.", + }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: false, enabled: false, CancellationToken.None); + + exitCode.Should().Be(0); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs new file mode 100644 index 00000000..12617a36 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for GsaService. +/// Uses TestHttpMessageHandler / CapturingHttpMessageHandler (defined in GraphApiServiceTests.cs, +/// same assembly) to inject fake platform responses. +/// +public class GsaServiceTests +{ + private static IAuthenticationService FakeAuth(string token = "fake-a365-token") + { + var mock = Substitute.For(); + mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(token)); + return mock; + } + + private static GsaService CreateService( + HttpMessageHandler handler, + IAuthenticationService? auth = null) => + new(NullLogger.Instance, auth ?? FakeAuth(), "prod", handler); + + private static HttpResponseMessage StatusResponse( + HttpStatusCode code, + string? status = null, + bool pending = false, + string? reason = null) => + new(code) + { + Content = new StringContent(JsonSerializer.Serialize(new { status, pending, reason })), + }; + + // ───────────────────────────────── SetAsync ───────────────────────────────── + + [Theory] + [InlineData(true, "/agents/gsa/enable")] + [InlineData(false, "/agents/gsa/disable")] + public async Task SetAsync_PostsToTheRouteThatCarriesTheIntent(bool enabled, string expectedPath) + { + HttpMethod? method = null; + Uri? uri = null; + using var handler = new CapturingHttpMessageHandler(r => + { + method = r.Method; + uri = r.RequestUri; + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, enabled ? "Enabled" : "Disabled")); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled); + + result.Should().NotBeNull(); + result!.Status.Should().Be(enabled ? "Enabled" : "Disabled"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + + method.Should().Be(HttpMethod.Post); + uri!.AbsolutePath.Should().Be(expectedPath); + } + + [Fact] + public async Task SetAsync_SendsNoEnvironmentIdentifierBecauseThePlatformResolvesIt() + { + string? body = null; + Uri? uri = null; + using var handler = new CapturingHttpMessageHandler(r => + { + uri = r.RequestUri; + body = r.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler); + + await svc.SetAsync(enabled: true); + + body.Should().BeEmpty(); + uri!.Query.Should().BeEmpty(); + } + + [Fact] + public async Task SetAsync_WhenAccepted_SurfacesThePendingFlagWithTheOldStatus() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.Accepted, "Disabled", pending: true)); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Disabled"); + result.Pending.Should().BeTrue(); + result.Reason.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_WhenGovernedByPolicy_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + error = "A Power Platform policy governs this setting.", + })), + }); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(1, because: "a governed setting cannot be fixed by retrying"); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadGateway)] + public async Task SetAsync_WhenTheCallFails_ReturnsNull(HttpStatusCode code) + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(code) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "nope" })), + }); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: false); + + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_WhenTheErrorBodyIsNotJson_StillReturnsNullWithoutThrowing() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("gateway"), + }); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_WhenNoTokenIsAvailable_ReturnsNullWithoutCallingThePlatform() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, FakeAuth(token: string.Empty)); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task SetAsync_WhenTheTransportThrows_ReturnsNull() + { + using var handler = new ExceptionThrowingHttpMessageHandler( + () => new HttpRequestException("connection reset")); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + } + + // ──────────────────────────────── GetStatusAsync ──────────────────────────── + + [Fact] + public async Task GetStatusAsync_GetsTheStatusRoute() + { + HttpMethod? method = null; + Uri? uri = null; + using var handler = new CapturingHttpMessageHandler(r => + { + method = r.Method; + uri = r.RequestUri; + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotConfigured")); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be("NotConfigured"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + + method.Should().Be(HttpMethod.Get); + uri!.AbsolutePath.Should().Be("/agents/gsa/status"); + } + + [Fact] + public async Task GetStatusAsync_WhenTheBodyIsEmpty_ReturnsAnEmptyStatus() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty), + }); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().BeNull(); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + } + + [Fact] + public async Task GetStatusAsync_SurfacesTheReason() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse( + HttpStatusCode.OK, "NotConfigured", reason: "This tenant has no Agent 365 environment yet.")); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be("NotConfigured"); + result.Pending.Should().BeFalse(); + result.Reason.Should().Be("This tenant has no Agent 365 environment yet."); + } + + // ─────────────────────────────── WaitForStatusAsync ───────────────────────── + + [Fact] + public async Task WaitForStatusAsync_WithoutAnExpectedStatus_Throws() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var act = () => svc.WaitForStatusAsync(" ", TimeSpan.FromMinutes(1)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task WaitForStatusAsync_WhenTheFirstReadAlreadyMatches_StopsImmediately() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler); + + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Enabled"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForStatusAsync_MatchesStatusCaseInsensitively() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "enabled")); + var svc = CreateService(handler); + + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("enabled"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForStatusAsync_WhenAReadFails_GivesUpRatherThanSpinning() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "upstream" })), + }); + var svc = CreateService(handler); + + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForStatusAsync_WhenTheBudgetCannotCoverAnotherPoll_ReturnsTheLastRead() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled", pending: true)); + var svc = CreateService(handler); + + // Shorter than the poll interval, so the first non-matching read is also the last. + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromSeconds(1)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Disabled"); + result.Pending.Should().BeTrue(); + result.Reason.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } +} From 2649ed8d90f53527b404a6f34a7058fd44db3cb5 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Tue, 15 Sep 2026 17:23:44 -0700 Subject: [PATCH 3/9] Target the az-login tenant when authenticating GSA commands GsaService asked for a token with a login hint but no tenant, so the authority stayed `common`. The Windows broker ignores the hint in that case and returns whichever account Windows prefers; the resulting UPN mismatch is only logged at Debug, so a tenant-wide setting could be applied to the wrong tenant without any visible warning. Passing the tenant also arms the existing mismatch self-heal in AuthenticationService, which is inert while tenantId is null. Resolve both tenant and user from a single `az account show` via IAzureCliService - the same source `vnet link` already uses - rather than adding a --tenant-id option the user would have to keep in sync with their az context. No az login, or an account with no tenant, now fails with a clear message instead of silently guessing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands/network-gsa.md | 10 +- .../Program.cs | 1 + .../Services/GsaService.cs | 20 +++- .../Services/GsaServiceTests.cs | 108 +++++++++++++++++- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md index 7dafb26b..8151e6f0 100644 --- a/docs/commands/network-gsa.md +++ b/docs/commands/network-gsa.md @@ -14,9 +14,15 @@ platform to apply the change against the environment it resolves for your tenant - **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects anyone else. +- An `az login` to the tenant you intend to configure. - Public cloud only. Sovereign clouds are not supported. -No `az login` is needed — unlike `a365 network vnet`, nothing is read from Azure. +Nothing is read from Azure — unlike `a365 network vnet` — but the `az login` still matters: it is +what selects the tenant. The commands authenticate against the tenant and account of your current +`az account show`, so `az login --tenant ` is how you choose which tenant to configure when you +have more than one. Without an explicit tenant the Windows broker silently returns whichever +account Windows prefers, which would apply a tenant-wide setting to the wrong tenant. If the +account you are signed into cannot be matched, the command fails rather than falling back. ## Subcommands @@ -78,3 +84,5 @@ a365 network gsa status | `409`, reporting a governing policy | A Power Platform policy owns this setting. Change it through that policy; the environment-level value is ignored while the policy applies. | | `404`, reporting no environment | The tenant has no Agent 365 environment yet. | | Status stays `NotConfigured` after `disable` | Read it again — the change is applied asynchronously and `--wait` is the way to block on it. | +| `Could not determine your Azure tenant` | No usable `az login`. Run `az login --tenant ` for the tenant you want to configure. | +| Sign-in prompt names the wrong account | The tenant comes from `az account show`. Run `az account set` / `az login --tenant ` to point at the intended tenant, then retry. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index d5fecab0..46990a0f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -392,6 +392,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(provider => new GsaService( provider.GetRequiredService>(), provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService().Environment)); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs index 0de886d3..1aee0e7c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs @@ -31,17 +31,20 @@ public class GsaService : IGsaService private readonly ILogger _logger; private readonly IAuthenticationService _authService; + private readonly IAzureCliService _azureCliService; private readonly string _environment; private readonly HttpMessageHandler? _handler; public GsaService( ILogger logger, IAuthenticationService authService, + IAzureCliService azureCliService, string environment = "prod", HttpMessageHandler? handler = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _azureCliService = azureCliService ?? throw new ArgumentNullException(nameof(azureCliService)); _environment = environment ?? "prod"; _handler = handler; } @@ -104,8 +107,21 @@ public GsaService( try { var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); - var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + + // Authenticate against the tenant of the current az login, not whichever account the + // Windows broker happens to prefer. Without an explicit tenant the authority is + // "common", and WAM silently returns the Windows account even when a login hint names + // a different one — so a tenant-wide setting would be changed on the wrong tenant. + // Passing the tenant also arms the mismatch self-heal in AuthenticationService. + var account = await _azureCliService.GetCurrentAccountAsync(); + if (account is null || string.IsNullOrWhiteSpace(account.TenantId)) + { + _logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again."); + return null; + } + + var authToken = await _authService.GetAccessTokenAsync( + audience, account.TenantId, userId: account.User.Name, ct: cancellationToken); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire an Agent 365 access token."); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs index 12617a36..45236c9f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -4,6 +4,7 @@ using System.Net; using System.Text.Json; using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -27,10 +28,24 @@ private static IAuthenticationService FakeAuth(string token = "fake-a365-token") return mock; } + private static IAzureCliService FakeAzureCli( + string tenantId = "11111111-1111-1111-1111-111111111111", + string upn = "admin@contoso.onmicrosoft.com") + { + var mock = Substitute.For(); + mock.GetCurrentAccountAsync().Returns(Task.FromResult(new AzureAccountInfo + { + TenantId = tenantId, + User = new AzureUser { Name = upn }, + })); + return mock; + } + private static GsaService CreateService( HttpMessageHandler handler, - IAuthenticationService? auth = null) => - new(NullLogger.Instance, auth ?? FakeAuth(), "prod", handler); + IAuthenticationService? auth = null, + IAzureCliService? azureCli = null) => + new(NullLogger.Instance, auth ?? FakeAuth(), azureCli ?? FakeAzureCli(), "prod", handler); private static HttpResponseMessage StatusResponse( HttpStatusCode code, @@ -180,6 +195,95 @@ public async Task SetAsync_WhenTheTransportThrows_ReturnsNull() result.Should().BeNull(); } + // ─────────────────────────── Tenant targeting ─────────────────────────── + // + // The tenant of the current az login is passed explicitly to token acquisition. Without it + // the authority is "common", and the Windows broker silently returns the Windows account even + // when a login hint names a different one — which would apply a tenant-wide setting to the + // wrong tenant. Passing the tenant also arms the mismatch self-heal in AuthenticationService. + + [Fact] + public async Task SetAsync_AuthenticatesAgainstTheTenantAndUserOfTheCurrentAzLogin() + { + const string tenantId = "22222222-2222-2222-2222-222222222222"; + const string upn = "admin@fabrikam.onmicrosoft.com"; + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler, auth, FakeAzureCli(tenantId, upn)); + + await svc.SetAsync(enabled: true); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + tenantId, + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + upn, + Arg.Any()); + } + + [Fact] + public async Task GetStatusAsync_AuthenticatesAgainstTheTenantAndUserOfTheCurrentAzLogin() + { + const string tenantId = "33333333-3333-3333-3333-333333333333"; + const string upn = "reader@fabrikam.onmicrosoft.com"; + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled")); + var svc = CreateService(handler, auth, FakeAzureCli(tenantId, upn)); + + await svc.GetStatusAsync(); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + tenantId, + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + upn, + Arg.Any()); + } + + [Fact] + public async Task SetAsync_WhenAzLoginIsUnavailable_ReturnsNullWithoutCallingThePlatform() + { + var azureCli = Substitute.For(); + azureCli.GetCurrentAccountAsync().Returns(Task.FromResult(null)); + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, azureCli: azureCli); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task SetAsync_WhenTheAzAccountCarriesNoTenant_ReturnsNullWithoutCallingThePlatform() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, azureCli: FakeAzureCli(tenantId: string.Empty)); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public void Constructor_WithoutAnAzureCliService_Throws() + { + using var handler = new TestHttpMessageHandler(); + + var act = () => new GsaService(NullLogger.Instance, FakeAuth(), null!, "prod", handler); + + act.Should().Throw().WithParameterName("azureCliService"); + } + // ──────────────────────────────── GetStatusAsync ──────────────────────────── [Fact] From 16ef241c590b0113f354541dbec40322c1d60ba5 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Wed, 23 Sep 2026 17:48:29 -0700 Subject: [PATCH 4/9] fix: address review feedback on a365 network Carries the vnet fixes from #494 (this branch contains that change set) and applies the same treatment to the gsa subcommands. Pin the enterprise-policy ARM id to its expected shape before concatenating it onto the ARM base URL. The base has no trailing slash and the ARM bearer token is a default request header, so `--policy-arm-id "@evil.example/x"` produced `https://management.azure.com@evil.example/x` -- userinfo, not host -- and sent the token to the attacker. Also: - Treat `NotStarted` as in-flight, matching what network.md documents. - Acquire the Agent 365 token for the resolved tenant rather than the signed-in default. `vnet unlink` and `vnet status` gain `--tenant-id` so they can do the same; the gsa commands already resolve the az-login tenant themselves. - Confirm before `vnet link --swap`, `vnet unlink`, `gsa enable` and `gsa disable`, with `--yes` for automation. Plain `vnet link` is not gated: a different existing link is reported as a conflict rather than replaced. - Reject an explicitly blank `--tenant-id` instead of silently falling back. - Use `CommandNames.Network` rather than a literal. - Drive every handler through `InvokeAsync` in tests. The previous doc comment claimed `ReportAsync` covered them, but tenant resolution, confirmation, service calls and exit codes were untested. - Stop the VNet tests shelling out to `az account show` via `AzCliHelper`'s static cache, using the repo's existing `loginHintResolver` seam. - Trim the CHANGELOG entries and reference #494 and #497. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +- docs/commands/network-gsa.md | 6 +- docs/commands/network.md | 13 +- .../Commands/NetworkCommand.cs | 200 ++++++-- .../Program.cs | 3 +- .../Services/ArmApiService.cs | 21 + .../Services/IVNetLinkService.cs | 9 +- .../Services/VNetLinkService.cs | 34 +- .../Commands/NetworkCommandTests.cs | 438 ++++++++++++++++-- .../Services/ArmApiServiceTests.cs | 27 +- .../Services/VNetLinkServiceTests.cs | 98 +++- 11 files changed, 763 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 921f3f3d..bad6dfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added -- `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. Global Secure Access is a per-environment Power Platform setting, and the Agent 365 managed environment's id is not published, so the platform resolves that environment and applies the change on your behalf. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md). -- `a365 network vnet link|unlink|status` — links an Azure virtual network to Agent 365 through a Power Platform NetworkInjection enterprise policy. Replaces `Enable-SubnetInjection` from the `Microsoft.PowerPlatform.EnterprisePolicies` module, which cannot be used because it requires the id of the Agent 365 managed environment and that id is not published. The CLI reads the policy's `systemId` from Azure with your existing `az login` and the platform performs the link against the environment it resolves for your tenant. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network.md](docs/commands/network.md). +- `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md) (#497). +- `a365 network vnet link|unlink|status` — links an Azure virtual network to Agent 365 through a Power Platform NetworkInjection enterprise policy, replacing `Enable-SubnetInjection` (#494). Requires Global Administrator or Power Platform Administrator. See [docs/commands/network.md](docs/commands/network.md). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md index 8151e6f0..fb616d74 100644 --- a/docs/commands/network-gsa.md +++ b/docs/commands/network-gsa.md @@ -35,14 +35,16 @@ account you are signed into cannot be matched, the command fails rather than fal ### `enable` and `disable` ```bash -a365 network gsa enable [--wait] -a365 network gsa disable [--wait] +a365 network gsa enable [--wait] [--yes] +a365 network gsa disable [--wait] [--yes] ``` | Option | Description | | --- | --- | | `--wait` | Keep polling until the change appears on the environment, instead of returning while it is still being applied. | +| `--yes`, `-y` | Skip the confirmation prompt. | +Both verbs prompt before changing the tenant-wide setting; pass `--yes` in automation. Requesting the value the environment already holds is a no-op and succeeds. ### `status` diff --git a/docs/commands/network.md b/docs/commands/network.md index 31123e53..0fdcb746 100644 --- a/docs/commands/network.md +++ b/docs/commands/network.md @@ -39,30 +39,32 @@ subnets, delegate them to `Microsoft.PowerPlatform/enterprisePolicies`, and crea ### `link` ```bash -a365 network vnet link --policy-arm-id [--swap] [--tenant-id ] [--wait] +a365 network vnet link --policy-arm-id [--swap] [--tenant-id ] [--wait] [--yes] ``` | Option | Description | | --- | --- | | `--policy-arm-id`, `-p` | **Required.** ARM resource id of the policy, as returned by `New-SubnetInjectionEnterprisePolicy`. | | `--swap` | Replace an existing link to a *different* policy. Without it, a different existing link is reported as a conflict instead of being silently replaced. | -| `--tenant-id` | Tenant to authenticate against for the Azure policy read. Defaults to the tenant of your current `az login`. | +| `--tenant-id` | Tenant to authenticate against. Defaults to the tenant of your current `az login`. | | `--wait` | Poll until the operation settles instead of returning an operation id. | +| `--yes`, `-y` | Skip the confirmation prompt shown for `--swap`. | Linking the policy that is already linked is a no-op and succeeds without `--swap`. ### `unlink` ```bash -a365 network vnet unlink [--wait] +a365 network vnet unlink [--tenant-id ] [--wait] [--yes] ``` -Unlink needs no policy id — the platform remembers which policy it linked. +Unlink needs no policy id — the platform remembers which policy it linked. It prompts before +removing the link; pass `--yes` in automation. ### `status` ```bash -a365 network vnet status [--operation-id ] +a365 network vnet status [--operation-id ] [--tenant-id ] ``` Without `--operation-id`, reports the environment's current link. With one, reports that specific @@ -100,6 +102,7 @@ a365 network vnet status | Symptom | Cause | | --- | --- | | `Could not determine your Azure tenant` | No `az login` session. Run `az login`, or pass `--tenant-id`. | +| `--tenant-id was supplied but is empty` | `--tenant-id` was passed with a blank value. Pass a tenant id, or omit the option entirely. | | `403` from the platform | Caller is not a Global or Power Platform Administrator, or the CLI app lacks consent for the `AgentTools.VNet.*` scopes. | | Conflict reported on `link` | A *different* policy is already linked. Re-run with `--swap`, or `unlink` first. | | Policy read fails | The policy ARM id is wrong, or your `az login` identity cannot read it. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs index 2bca25a4..893f59ee 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; @@ -28,26 +29,29 @@ public static Command CreateCommand( ILogger logger, IVNetLinkService vnetLinkService, IAzureCliService azureCliService, - IGsaService gsaService) + IGsaService gsaService, + IConfirmationProvider confirmationProvider) { - var networkCommand = new Command("network", "Configure tenant networking for Agent 365"); + var networkCommand = new Command(CommandNames.Network, "Configure tenant networking for Agent 365"); var vnetCommand = new Command( "vnet", "Link an Azure virtual network enterprise policy to your Agent 365 environment. " + "Requires the Global Administrator or Power Platform Administrator role."); - vnetCommand.AddCommand(CreateLinkSubcommand(logger, vnetLinkService, azureCliService)); - vnetCommand.AddCommand(CreateUnlinkSubcommand(logger, vnetLinkService)); - vnetCommand.AddCommand(CreateStatusSubcommand(logger, vnetLinkService)); + vnetCommand.AddCommand(CreateLinkSubcommand(logger, vnetLinkService, azureCliService, confirmationProvider)); + vnetCommand.AddCommand(CreateUnlinkSubcommand(logger, vnetLinkService, azureCliService, confirmationProvider)); + vnetCommand.AddCommand(CreateStatusSubcommand(logger, vnetLinkService, azureCliService)); var gsaCommand = new Command( "gsa", "Turn Global Secure Access on or off for your Agent 365 environment. " + "Requires the Global Administrator or Power Platform Administrator role."); - gsaCommand.AddCommand(CreateGsaSetSubcommand(logger, gsaService, enabled: true)); - gsaCommand.AddCommand(CreateGsaSetSubcommand(logger, gsaService, enabled: false)); + gsaCommand.AddCommand(CreateGsaSetSubcommand( + logger, gsaService, azureCliService, confirmationProvider, enabled: true)); + gsaCommand.AddCommand(CreateGsaSetSubcommand( + logger, gsaService, azureCliService, confirmationProvider, enabled: false)); gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService)); networkCommand.AddCommand(vnetCommand); @@ -55,10 +59,67 @@ public static Command CreateCommand( return networkCommand; } + /// + /// Resolves the tenant to authenticate against, or logs why it could not and returns null. + /// + /// + /// An explicitly blank --tenant-id is treated as a mistake rather than as a request for + /// the default. Falling back silently would run a tenant-wide change against whichever tenant + /// az happens to be signed in to, which is not what someone who typed the option meant. + /// + internal static async Task ResolveTenantIdAsync( + ILogger logger, + IAzureCliService azureCliService, + string? tenantIdOption) + { + if (tenantIdOption is not null) + { + if (string.IsNullOrWhiteSpace(tenantIdOption)) + { + logger.LogError( + "--tenant-id was supplied but is empty. Pass a tenant id, or omit the option " + + "to use the tenant of your current az login."); + return null; + } + + return tenantIdOption; + } + + var account = await azureCliService.GetCurrentAccountAsync(); + var tenantId = account?.TenantId; + if (string.IsNullOrWhiteSpace(tenantId)) + { + logger.LogError("Could not determine your Azure tenant. Run 'az login', or pass --tenant-id."); + return null; + } + + return tenantId; + } + + /// + /// Asks the operator to confirm a change to tenant-wide networking, naming the tenant and the + /// action so the prompt is answerable without scrolling back. + /// + internal static async Task ConfirmChangeAsync( + IConfirmationProvider confirmationProvider, + bool yes, + string action, + string tenantId) + { + if (yes) + { + return true; + } + + return await confirmationProvider.ConfirmAsync( + $"{action} for tenant {tenantId}. This changes networking for every Agent 365 agent in the tenant. Continue?"); + } + private static Command CreateLinkSubcommand( ILogger logger, IVNetLinkService vnetLinkService, - IAzureCliService azureCliService) + IAzureCliService azureCliService, + IConfirmationProvider confirmationProvider) { var command = new Command( "link", @@ -90,40 +151,55 @@ private static Command CreateLinkSubcommand( var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + var yesOption = new Option( + ["--yes", "-y"], + "Skip the confirmation prompt shown when --swap would replace an existing link."); + command.AddOption(policyArmIdOption); command.AddOption(swapOption); command.AddOption(tenantIdOption); command.AddOption(waitOption); + command.AddOption(yesOption); command.AddOption(verboseOption); command.SetHandler(async (InvocationContext context) => { var policyArmId = context.ParseResult.GetValueForOption(policyArmIdOption)!; var swap = context.ParseResult.GetValueForOption(swapOption); - var tenantId = context.ParseResult.GetValueForOption(tenantIdOption); + var tenantIdOptionValue = context.ParseResult.GetValueForOption(tenantIdOption); var wait = context.ParseResult.GetValueForOption(waitOption); + var yes = context.ParseResult.GetValueForOption(yesOption); var ct = context.GetCancellationToken(); - if (string.IsNullOrWhiteSpace(tenantId)) + var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOptionValue); + if (tenantId == null) + { + context.ExitCode = 1; + return; + } + + // Only --swap needs confirming: without it an existing different link is reported as a + // conflict rather than replaced, so the command is already non-destructive. + if (swap && !await ConfirmChangeAsync( + confirmationProvider, yes, "Replace the existing virtual network link", tenantId)) { - var account = await azureCliService.GetCurrentAccountAsync(); - tenantId = account?.TenantId; - if (string.IsNullOrWhiteSpace(tenantId)) - { - logger.LogError("Could not determine your Azure tenant. Run 'az login', or pass --tenant-id."); - context.ExitCode = 1; - return; - } + logger.LogInformation("Cancelled."); + context.ExitCode = 1; + return; } var result = await vnetLinkService.LinkAsync(policyArmId, swap, tenantId, ct); - context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Link", ct); + context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Link", tenantId, ct); }); return command; } - private static Command CreateUnlinkSubcommand(ILogger logger, IVNetLinkService vnetLinkService) + private static Command CreateUnlinkSubcommand( + ILogger logger, + IVNetLinkService vnetLinkService, + IAzureCliService azureCliService, + IConfirmationProvider confirmationProvider) { var command = new Command( "unlink", @@ -133,24 +209,54 @@ private static Command CreateUnlinkSubcommand(ILogger logger, IVNetLinkService v "--wait", "Keep polling until the unlink settles, instead of returning an operation id."); + var tenantIdOption = new Option( + "--tenant-id", + "Tenant to authenticate against. Defaults to the tenant of your current az login."); + + var yesOption = new Option( + ["--yes", "-y"], + "Skip the confirmation prompt."); + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); command.AddOption(waitOption); + command.AddOption(tenantIdOption); + command.AddOption(yesOption); command.AddOption(verboseOption); command.SetHandler(async (InvocationContext context) => { var wait = context.ParseResult.GetValueForOption(waitOption); + var tenantIdOptionValue = context.ParseResult.GetValueForOption(tenantIdOption); + var yes = context.ParseResult.GetValueForOption(yesOption); var ct = context.GetCancellationToken(); - var result = await vnetLinkService.UnlinkAsync(ct); - context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Unlink", ct); + var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOptionValue); + if (tenantId == null) + { + context.ExitCode = 1; + return; + } + + if (!await ConfirmChangeAsync( + confirmationProvider, yes, "Remove the virtual network link", tenantId)) + { + logger.LogInformation("Cancelled."); + context.ExitCode = 1; + return; + } + + var result = await vnetLinkService.UnlinkAsync(tenantId, ct); + context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Unlink", tenantId, ct); }); return command; } - private static Command CreateStatusSubcommand(ILogger logger, IVNetLinkService vnetLinkService) + private static Command CreateStatusSubcommand( + ILogger logger, + IVNetLinkService vnetLinkService, + IAzureCliService azureCliService) { var command = new Command( "status", @@ -160,17 +266,30 @@ private static Command CreateStatusSubcommand(ILogger logger, IVNetLinkService v "--operation-id", "Operation handle returned by a link or unlink that was still running."); + var tenantIdOption = new Option( + "--tenant-id", + "Tenant to authenticate against. Defaults to the tenant of your current az login."); + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); command.AddOption(operationIdOption); + command.AddOption(tenantIdOption); command.AddOption(verboseOption); command.SetHandler(async (InvocationContext context) => { var operationId = context.ParseResult.GetValueForOption(operationIdOption); + var tenantIdOptionValue = context.ParseResult.GetValueForOption(tenantIdOption); var ct = context.GetCancellationToken(); - var status = await vnetLinkService.GetStatusAsync(operationId, ct); + var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOptionValue); + if (tenantId == null) + { + context.ExitCode = 1; + return; + } + + var status = await vnetLinkService.GetStatusAsync(tenantId, operationId, ct); if (status == null) { context.ExitCode = 1; @@ -194,6 +313,7 @@ internal static async Task ReportAsync( VNetStatusResponse? result, bool wait, string operationLabel, + string tenantId, CancellationToken cancellationToken) { if (result == null) @@ -204,7 +324,7 @@ internal static async Task ReportAsync( if (wait && VNetLinkService.IsRunning(result.Status) && !string.IsNullOrWhiteSpace(result.OperationId)) { logger.LogInformation("{Operation} is running. Waiting for it to settle...", operationLabel); - result = await vnetLinkService.WaitForCompletionAsync(result.OperationId, DefaultWaitTimeout, cancellationToken); + result = await vnetLinkService.WaitForCompletionAsync(tenantId, result.OperationId, DefaultWaitTimeout, cancellationToken); if (result == null) { @@ -234,7 +354,12 @@ internal static async Task ReportAsync( /// Creates the gsa enable or disable subcommand. The two differ only in the value they send /// and the words they use, so they share one builder. /// - private static Command CreateGsaSetSubcommand(ILogger logger, IGsaService gsaService, bool enabled) + private static Command CreateGsaSetSubcommand( + ILogger logger, + IGsaService gsaService, + IAzureCliService azureCliService, + IConfirmationProvider confirmationProvider, + bool enabled) { var verb = enabled ? "enable" : "disable"; var command = new Command( @@ -246,16 +371,39 @@ private static Command CreateGsaSetSubcommand(ILogger logger, IGsaService gsaSer "Keep polling until the change appears on the environment, instead of returning while " + "it is still being applied."); + var yesOption = new Option( + ["--yes", "-y"], + "Skip the confirmation prompt."); + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); command.AddOption(waitOption); + command.AddOption(yesOption); command.AddOption(verboseOption); command.SetHandler(async (InvocationContext context) => { var wait = context.ParseResult.GetValueForOption(waitOption); + var yes = context.ParseResult.GetValueForOption(yesOption); var ct = context.GetCancellationToken(); + // GsaService authenticates against the same az login, so resolving here only names the + // tenant in the prompt — it does not pick a different one. + var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOption: null); + if (tenantId == null) + { + context.ExitCode = 1; + return; + } + + if (!await ConfirmChangeAsync( + confirmationProvider, yes, $"Turn Global Secure Access {(enabled ? "on" : "off")}", tenantId)) + { + logger.LogInformation("Cancelled."); + context.ExitCode = 1; + return; + } + var result = await gsaService.SetAsync(enabled, ct); context.ExitCode = await ReportGsaAsync(logger, gsaService, result, wait, enabled, ct); }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 46990a0f..393bdb0a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -189,7 +189,8 @@ await Task.WhenAll( var vnetLinkService = serviceProvider.GetRequiredService(); var azureCliService = serviceProvider.GetRequiredService(); var gsaService = serviceProvider.GetRequiredService(); - rootCommand.AddCommand(NetworkCommand.CreateCommand(networkLogger, vnetLinkService, azureCliService, gsaService)); + rootCommand.AddCommand(NetworkCommand.CreateCommand( + networkLogger, vnetLinkService, azureCliService, gsaService, confirmationProvider)); // Build pipeline manually so we can skip UseTypoCorrections() ("Did you mean?" noise) // and UseParseErrorReporting() (full help dump on any parse error), replacing both diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs index fee4ef14..06d5efd2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs @@ -8,6 +8,7 @@ using System.Net; using System.Net.Http.Headers; using System.Text.Json; +using System.Text.RegularExpressions; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -28,6 +29,15 @@ public class ArmApiService : IDisposable // Stable first: the module's own ARM templates deploy enterprise policies at 2020-10-30. private static readonly string[] EnterprisePolicyApiVersions = ["2020-10-30", "2020-10-30-preview"]; + // ArmBaseUrl has no trailing slash and the ARM bearer token is set as a default request + // header, so a policy id that does not begin with "/subscriptions/" can retarget the whole + // request: "@evil.example/x" concatenates to "https://management.azure.com@evil.example/x", + // where "management.azure.com" is userinfo and the host is the attacker's. Pinning the shape + // is what keeps the token pointed at ARM. + private static readonly Regex EnterprisePolicyArmIdPattern = new( + @"^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/[^/?#]+/providers/Microsoft\.PowerPlatform/enterprisePolicies/[^/?#]+$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly IAuthenticationService _authService; @@ -266,6 +276,17 @@ private async Task EnsureArmHeadersAsync(string tenantId, CancellationToke if (string.IsNullOrWhiteSpace(policyArmId)) throw new ArgumentException("Policy ARM id is required.", nameof(policyArmId)); + if (!EnterprisePolicyArmIdPattern.IsMatch(policyArmId)) + { + _logger.LogError( + "'{PolicyArmId}' is not an enterprise policy ARM id. Expected " + + "/subscriptions/{{subscriptionId}}/resourceGroups/{{group}}/providers/" + + "Microsoft.PowerPlatform/enterprisePolicies/{{name}}, as returned by " + + "New-SubnetInjectionEnterprisePolicy.", + policyArmId); + return null; + } + if (!await EnsureArmHeadersAsync(tenantId, ct)) return null; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs index ff0037ee..eb1854da 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs @@ -29,28 +29,35 @@ public interface IVNetLinkService /// /// Removes the current link. The platform supplies the policy identifier it stored at link time. /// + /// Tenant to authenticate against. /// Cancellation token. /// The resulting status, or null when the operation could not be started. - Task UnlinkAsync(CancellationToken cancellationToken = default); + Task UnlinkAsync( + string tenantId, + CancellationToken cancellationToken = default); /// /// Reads the current link status, optionally resuming a specific operation handle. /// + /// Tenant to authenticate against. /// Handle returned by a link or unlink that was still running. /// Cancellation token. /// The current status, or null when it could not be read. Task GetStatusAsync( + string tenantId, string? operationId = null, CancellationToken cancellationToken = default); /// /// Polls status until the operation reaches a terminal state or the timeout elapses. /// + /// Tenant to authenticate against. /// Handle of the running operation. /// How long to keep polling. /// Cancellation token. /// The last status read, which may still be Running if the timeout elapsed. Task WaitForCompletionAsync( + string tenantId, string operationId, TimeSpan timeout, CancellationToken cancellationToken = default); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs index f8e19e1d..047d976c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs @@ -38,19 +38,22 @@ public class VNetLinkService : IVNetLinkService private readonly ArmApiService _armApiService; private readonly string _environment; private readonly HttpMessageHandler? _handler; + private readonly Func> _loginHintResolver; public VNetLinkService( ILogger logger, IAuthenticationService authService, ArmApiService armApiService, string environment = "prod", - HttpMessageHandler? handler = null) + HttpMessageHandler? handler = null, + Func>? loginHintResolver = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); _armApiService = armApiService ?? throw new ArgumentNullException(nameof(armApiService)); _environment = environment ?? "prod"; _handler = handler; + _loginHintResolver = loginHintResolver ?? AzCliHelper.ResolveLoginHintAsync; } /// @@ -79,18 +82,21 @@ public VNetLinkService( }; _logger.LogInformation("Linking the policy to your Agent 365 environment..."); - return await SendAsync(HttpMethod.Post, LinkPath, request, "link virtual network", cancellationToken); + return await SendAsync(HttpMethod.Post, LinkPath, request, "link virtual network", tenantId, cancellationToken); } /// - public async Task UnlinkAsync(CancellationToken cancellationToken = default) + public async Task UnlinkAsync( + string tenantId, + CancellationToken cancellationToken = default) { _logger.LogInformation("Removing the virtual network link from your Agent 365 environment..."); - return await SendAsync(HttpMethod.Post, UnlinkPath, payload: null, "unlink virtual network", cancellationToken); + return await SendAsync(HttpMethod.Post, UnlinkPath, payload: null, "unlink virtual network", tenantId, cancellationToken); } /// public async Task GetStatusAsync( + string tenantId, string? operationId = null, CancellationToken cancellationToken = default) { @@ -98,11 +104,12 @@ public VNetLinkService( ? StatusPath : $"{StatusPath}?operationId={Uri.EscapeDataString(operationId)}"; - return await SendAsync(HttpMethod.Get, path, payload: null, "read virtual network status", cancellationToken); + return await SendAsync(HttpMethod.Get, path, payload: null, "read virtual network status", tenantId, cancellationToken); } /// public async Task WaitForCompletionAsync( + string tenantId, string operationId, TimeSpan timeout, CancellationToken cancellationToken = default) @@ -117,7 +124,7 @@ public VNetLinkService( while (true) { - last = await GetStatusAsync(operationId, cancellationToken); + last = await GetStatusAsync(tenantId, operationId, cancellationToken); if (last == null || !IsRunning(last.Status)) return last; @@ -132,15 +139,20 @@ public VNetLinkService( /// /// True when the reported status means the operation has not settled yet. + /// + /// The platform reports a queued operation as NotStarted, which is as unsettled as Running: + /// treating it as terminal would make --wait return before the work had begun. /// public static bool IsRunning(string? status) => - string.Equals(status, "Running", StringComparison.OrdinalIgnoreCase); + string.Equals(status, "Running", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "NotStarted", StringComparison.OrdinalIgnoreCase); private async Task SendAsync( HttpMethod method, string path, object? payload, string operationName, + string tenantId, CancellationToken cancellationToken) { var correlationId = HttpClientFactory.GenerateCorrelationId(); @@ -150,8 +162,12 @@ public static bool IsRunning(string? status) => try { var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); - var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var loginHint = await _loginHintResolver(); + + // The tenant matters as much here as on the ARM read: without it MSAL falls back to + // the common authority with only a login hint, so on a machine with several cached + // accounts the platform call can land in a different tenant than the policy read. + var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, userId: loginHint, ct: cancellationToken); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire an Agent 365 access token."); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs index 322a0c05..57db251f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -14,23 +14,44 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// -/// Unit tests for the network command tree and its result reporting. -/// The subcommand handlers themselves are exercised through ReportAsync, which holds the -/// wait-and-exit-code logic; the handlers around it only parse options. +/// Unit tests for the network command tree, its handlers and its result reporting. +/// Handlers are driven through InvokeAsync against substituted services so that tenant +/// resolution, confirmation and exit codes are covered, not just option parsing. /// public class NetworkCommandTests { private const string OperationId = "op-abc"; + private const string TenantId = "tid"; + + private const string PolicyArmId = + "/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p"; private static Command CreateCommand( IVNetLinkService? vnet = null, IAzureCliService? azure = null, + IConfirmationProvider? confirmation = null, IGsaService? gsa = null) => NetworkCommand.CreateCommand( NullLogger.Instance, vnet ?? Substitute.For(), - azure ?? Substitute.For(), - gsa ?? Substitute.For()); + azure ?? SignedInAzureCli(), + gsa ?? Substitute.For(), + confirmation ?? Confirming(true)); + + private static IAzureCliService SignedInAzureCli(string? tenantId = TenantId) + { + var azure = Substitute.For(); + azure.GetCurrentAccountAsync().Returns( + Task.FromResult(tenantId == null ? null : new AzureAccountInfo { TenantId = tenantId })); + return azure; + } + + private static IConfirmationProvider Confirming(bool answer) + { + var confirmation = Substitute.For(); + confirmation.ConfirmAsync(Arg.Any()).Returns(Task.FromResult(answer)); + return confirmation; + } // ──────────────────────────── Command tree shape ──────────────────────────── @@ -62,7 +83,7 @@ public void LinkSubcommand_RequiresPolicyArmIdAndOffersTheDocumentedOptions() var link = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "link"); link.Options.Select(o => o.Name).Should() - .BeEquivalentTo("policy-arm-id", "swap", "tenant-id", "wait", "verbose"); + .BeEquivalentTo("policy-arm-id", "swap", "tenant-id", "wait", "yes", "verbose"); link.Options.Single(o => o.Name == "policy-arm-id").IsRequired.Should().BeTrue(); link.Options.Single(o => o.Name == "swap").IsRequired.Should().BeFalse(); } @@ -72,7 +93,7 @@ public void UnlinkSubcommand_TakesNoPolicyBecauseThePlatformStoredIt() { var unlink = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "unlink"); - unlink.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "verbose"); + unlink.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "tenant-id", "yes", "verbose"); } [Fact] @@ -80,7 +101,7 @@ public void StatusSubcommand_AcceptsAnOperationHandle() { var status = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "status"); - status.Options.Select(o => o.Name).Should().BeEquivalentTo("operation-id", "verbose"); + status.Options.Select(o => o.Name).Should().BeEquivalentTo("operation-id", "tenant-id", "verbose"); } [Fact] @@ -103,6 +124,244 @@ public void LinkSubcommand_WithoutPolicyArmId_FailsToParse() parsed.Errors.Should().NotBeEmpty(because: "--policy-arm-id is required"); } + // ──────────────────────────── Handler invocation ──────────────────────────── + + [Fact] + public async Task LinkHandler_ResolvesTheTenantFromAzLoginAndCallsTheService() + { + var vnet = Substitute.For(); + vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); + + exitCode.Should().Be(0); + await vnet.Received(1).LinkAsync(PolicyArmId, false, TenantId, Arg.Any()); + } + + [Fact] + public async Task LinkHandler_PrefersAnExplicitTenantOverTheAzLoginTenant() + { + var vnet = Substitute.For(); + vnet.LinkAsync(PolicyArmId, false, "other-tenant", Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync( + $"vnet link --policy-arm-id {PolicyArmId} --tenant-id other-tenant"); + + exitCode.Should().Be(0); + await vnet.Received(1).LinkAsync(PolicyArmId, false, "other-tenant", Arg.Any()); + } + + [Fact] + public async Task LinkHandler_WithoutSwap_DoesNotPrompt() + { + var vnet = Substitute.For(); + vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); + var confirmation = Confirming(false); + var command = CreateCommand(vnet, confirmation: confirmation); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); + + exitCode.Should().Be(0, because: "a conflicting link is reported, not replaced, without --swap"); + await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); + } + + [Fact] + public async Task LinkHandler_WhenSwapDeclined_DoesNotCallTheService() + { + var vnet = Substitute.For(); + var command = CreateCommand(vnet, confirmation: Confirming(false)); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId} --swap"); + + exitCode.Should().Be(1); + await vnet.DidNotReceive().LinkAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task LinkHandler_WhenSwapAndYes_SkipsThePromptAndCallsTheService() + { + var vnet = Substitute.For(); + vnet.LinkAsync(PolicyArmId, true, TenantId, Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); + var confirmation = Confirming(false); + var command = CreateCommand(vnet, confirmation: confirmation); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId} --swap --yes"); + + exitCode.Should().Be(0); + await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); + await vnet.Received(1).LinkAsync(PolicyArmId, true, TenantId, Arg.Any()); + } + + [Fact] + public async Task LinkHandler_WhenTheServiceFails_ReturnsFailure() + { + var vnet = Substitute.For(); + vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) + .Returns(Task.FromResult(null)); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task LinkHandler_WithWait_PollsUntilTheOperationSettles() + { + var vnet = Substitute.For(); + vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) + .Returns(Task.FromResult( + new VNetStatusResponse { Status = "Running", OperationId = OperationId })); + vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId} --wait"); + + exitCode.Should().Be(0); + await vnet.Received(1).WaitForCompletionAsync( + TenantId, OperationId, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task LinkHandler_WhenNoTenantCanBeResolved_FailsWithoutCallingTheService() + { + var vnet = Substitute.For(); + var command = CreateCommand(vnet, SignedInAzureCli(tenantId: null)); + + var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); + + exitCode.Should().Be(1); + await vnet.DidNotReceive().LinkAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task LinkHandler_WhenTenantIdSuppliedButBlank_FailsWithoutFallingBackToAzLogin() + { + var vnet = Substitute.For(); + var azure = SignedInAzureCli(); + var command = CreateCommand(vnet, azure); + + var exitCode = await command.InvokeAsync( + ["vnet", "link", "--policy-arm-id", PolicyArmId, "--tenant-id", " "]); + + exitCode.Should().Be(1, because: "a blank tenant is a mistake, not a request for the default"); + await azure.DidNotReceive().GetCurrentAccountAsync(); + await vnet.DidNotReceive().LinkAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UnlinkHandler_PromptsThenCallsTheService() + { + var vnet = Substitute.For(); + vnet.UnlinkAsync(TenantId, Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "NotLinked" })); + var confirmation = Confirming(true); + var command = CreateCommand(vnet, confirmation: confirmation); + + var exitCode = await command.InvokeAsync("vnet unlink"); + + exitCode.Should().Be(0); + await confirmation.Received(1).ConfirmAsync(Arg.Any()); + await vnet.Received(1).UnlinkAsync(TenantId, Arg.Any()); + } + + [Fact] + public async Task UnlinkHandler_WhenDeclined_DoesNotCallTheService() + { + var vnet = Substitute.For(); + var command = CreateCommand(vnet, confirmation: Confirming(false)); + + var exitCode = await command.InvokeAsync("vnet unlink"); + + exitCode.Should().Be(1); + await vnet.DidNotReceive().UnlinkAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UnlinkHandler_WithYes_SkipsThePrompt() + { + var vnet = Substitute.For(); + vnet.UnlinkAsync(TenantId, Arg.Any()) + .Returns(Task.FromResult(new VNetStatusResponse { Status = "NotLinked" })); + var confirmation = Confirming(false); + var command = CreateCommand(vnet, confirmation: confirmation); + + var exitCode = await command.InvokeAsync("vnet unlink --yes"); + + exitCode.Should().Be(0); + await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); + await vnet.Received(1).UnlinkAsync(TenantId, Arg.Any()); + } + + [Fact] + public async Task StatusHandler_ReadsTheCurrentLinkWithoutPrompting() + { + var vnet = Substitute.For(); + vnet.GetStatusAsync(TenantId, null, Arg.Any()) + .Returns(Task.FromResult( + new VNetStatusResponse { Status = "Linked", PolicyArmId = PolicyArmId })); + var confirmation = Confirming(false); + var command = CreateCommand(vnet, confirmation: confirmation); + + var exitCode = await command.InvokeAsync("vnet status"); + + exitCode.Should().Be(0); + await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); + await vnet.Received(1).GetStatusAsync(TenantId, null, Arg.Any()); + } + + [Fact] + public async Task StatusHandler_PassesTheOperationHandleThrough() + { + var vnet = Substitute.For(); + vnet.GetStatusAsync(TenantId, OperationId, Arg.Any()) + .Returns(Task.FromResult( + new VNetStatusResponse { Status = "Running", OperationId = OperationId })); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync($"vnet status --operation-id {OperationId}"); + + exitCode.Should().Be(0); + await vnet.Received(1).GetStatusAsync(TenantId, OperationId, Arg.Any()); + } + + [Fact] + public async Task StatusHandler_WhenFailed_ReturnsFailure() + { + var vnet = Substitute.For(); + vnet.GetStatusAsync(TenantId, null, Arg.Any()) + .Returns(Task.FromResult( + new VNetStatusResponse { Status = "Failed", Reason = "Region mismatch." })); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync("vnet status"); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task StatusHandler_WhenStatusUnreadable_ReturnsFailure() + { + var vnet = Substitute.For(); + vnet.GetStatusAsync(TenantId, null, Arg.Any()) + .Returns(Task.FromResult(null)); + var command = CreateCommand(vnet); + + var exitCode = await command.InvokeAsync("vnet status"); + + exitCode.Should().Be(1); + } + // ───────────────────────────────── ReportAsync ────────────────────────────── [Fact] @@ -111,11 +370,10 @@ public async Task ReportAsync_WhenResultNull_ReturnsFailure() var vnet = Substitute.For(); var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result: null, wait: true, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result: null, wait: true, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(1); - await vnet.DidNotReceive().WaitForCompletionAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -125,11 +383,10 @@ public async Task ReportAsync_WhenSettled_ReturnsSuccessWithoutWaiting() var result = new VNetStatusResponse { Status = "Linked" }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(0); - await vnet.DidNotReceive().WaitForCompletionAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -139,7 +396,7 @@ public async Task ReportAsync_WhenFailed_ReturnsFailure() var result = new VNetStatusResponse { Status = "Failed", Reason = "Region mismatch." }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: false, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: false, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(1); } @@ -151,40 +408,38 @@ public async Task ReportAsync_WhenRunningAndNotWaiting_ReturnsSuccessAndLeavesTh var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: false, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: false, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(0, because: "an accepted operation is not itself a failure"); - await vnet.DidNotReceive().WaitForCompletionAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ReportAsync_WhenRunningAndWaiting_PollsThenReportsTheSettledStatus() { var vnet = Substitute.For(); - vnet.WaitForCompletionAsync(OperationId, Arg.Any(), Arg.Any()) + vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(0); - await vnet.Received(1).WaitForCompletionAsync( - OperationId, Arg.Any(), Arg.Any()); + await vnet.Received(1).WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()); } [Fact] public async Task ReportAsync_WhenWaitSettlesAsFailed_ReturnsFailure() { var vnet = Substitute.For(); - vnet.WaitForCompletionAsync(OperationId, Arg.Any(), Arg.Any()) + vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult( new VNetStatusResponse { Status = "Failed", Reason = "Upstream rejected the link." })); var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(1); } @@ -193,12 +448,12 @@ public async Task ReportAsync_WhenWaitSettlesAsFailed_ReturnsFailure() public async Task ReportAsync_WhenWaitCannotReadStatus_ReturnsFailure() { var vnet = Substitute.For(); - vnet.WaitForCompletionAsync(OperationId, Arg.Any(), Arg.Any()) + vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(1); } @@ -210,11 +465,10 @@ public async Task ReportAsync_WhenRunningWithoutAHandle_DoesNotWait() var result = new VNetStatusResponse { Status = "Running", OperationId = null }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); exitCode.Should().Be(0); - await vnet.DidNotReceive().WaitForCompletionAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -224,7 +478,7 @@ public async Task ReportAsync_WhenUnlinkSettles_ReturnsSuccess() var result = new VNetStatusResponse { Status = "NotLinked" }; var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Unlink", CancellationToken.None); + NullLogger.Instance, vnet, result, wait: true, "Unlink", TenantId, CancellationToken.None); exitCode.Should().Be(0); } @@ -234,13 +488,133 @@ public async Task ReportAsync_WhenUnlinkSettles_ReturnsSuccess() [Theory] [InlineData("enable")] [InlineData("disable")] - public void GsaSetSubcommands_OfferWaitAndVerboseOnly(string name) + public void GsaSetSubcommands_OfferWaitYesAndVerboseOnly(string name) { var gsa = CreateCommand().Subcommands.Single(c => c.Name == "gsa"); var subcommand = gsa.Subcommands.Single(c => c.Name == name); - subcommand.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "verbose"); + subcommand.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "yes", "verbose"); + } + + // ───────────────────────── GSA handler invocation ─────────────────────────── + + [Theory] + [InlineData("enable", true)] + [InlineData("disable", false)] + public async Task GsaSetHandler_PromptsThenAppliesTheRequestedValue(string verb, bool enabled) + { + var gsa = Substitute.For(); + gsa.SetAsync(enabled, Arg.Any()) + .Returns(Task.FromResult( + new GsaStatusResponse { Status = enabled ? "Enabled" : "Disabled" })); + var confirmation = Confirming(true); + var command = CreateCommand(confirmation: confirmation, gsa: gsa); + + var exitCode = await command.InvokeAsync($"gsa {verb}"); + + exitCode.Should().Be(0); + await confirmation.Received(1).ConfirmAsync(Arg.Any()); + await gsa.Received(1).SetAsync(enabled, Arg.Any()); + } + + [Theory] + [InlineData("enable")] + [InlineData("disable")] + public async Task GsaSetHandler_WhenDeclined_DoesNotCallTheService(string verb) + { + var gsa = Substitute.For(); + var command = CreateCommand(confirmation: Confirming(false), gsa: gsa); + + var exitCode = await command.InvokeAsync($"gsa {verb}"); + + exitCode.Should().Be(1); + await gsa.DidNotReceive().SetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GsaSetHandler_WithYes_SkipsThePrompt() + { + var gsa = Substitute.For(); + gsa.SetAsync(true, Arg.Any()) + .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); + var confirmation = Confirming(false); + var command = CreateCommand(confirmation: confirmation, gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa enable --yes"); + + exitCode.Should().Be(0); + await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); + await gsa.Received(1).SetAsync(true, Arg.Any()); + } + + [Fact] + public async Task GsaSetHandler_WhenNoTenantCanBeResolved_FailsWithoutCallingTheService() + { + var gsa = Substitute.For(); + var command = CreateCommand(azure: SignedInAzureCli(tenantId: null), gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa enable"); + + exitCode.Should().Be(1); + await gsa.DidNotReceive().SetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GsaSetHandler_WhenTheServiceFails_ReturnsFailure() + { + var gsa = Substitute.For(); + gsa.SetAsync(true, Arg.Any()).Returns(Task.FromResult(null)); + var command = CreateCommand(gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa enable --yes"); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task GsaSetHandler_WithWait_PollsForTheRequestedValue() + { + var gsa = Substitute.For(); + gsa.SetAsync(true, Arg.Any()) + .Returns(Task.FromResult( + new GsaStatusResponse { Status = "Disabled", Pending = true })); + gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); + var command = CreateCommand(gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa enable --yes --wait"); + + exitCode.Should().Be(0); + await gsa.Received(1).WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GsaStatusHandler_ReadsStatusWithoutPrompting() + { + var gsa = Substitute.For(); + gsa.GetStatusAsync(Arg.Any()) + .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); + var confirmation = Confirming(false); + var command = CreateCommand(confirmation: confirmation, gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa status"); + + exitCode.Should().Be(0); + await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); + await gsa.Received(1).GetStatusAsync(Arg.Any()); + } + + [Fact] + public async Task GsaStatusHandler_WhenStatusUnreadable_ReturnsFailure() + { + var gsa = Substitute.For(); + gsa.GetStatusAsync(Arg.Any()).Returns(Task.FromResult(null)); + var command = CreateCommand(gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa status"); + + exitCode.Should().Be(1); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs index a140d33e..c6dfa2c2 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs @@ -304,7 +304,7 @@ private static HttpResponseMessage BuildRoleAssignmentsResponse(string scope, st // ──────────────────────── GetEnterprisePolicySystemIdAsync ──────────────────────── private const string PolicyArmId = - "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.PowerPlatform/enterprisePolicies/policy-1"; + "/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.PowerPlatform/enterprisePolicies/policy-1"; private const string PolicySystemId = "/regions/unitedstates/providers/Microsoft.PowerPlatform/enterprisePolicies/1b2c8a4e-0000-0000-0000-000000000000"; @@ -312,6 +312,31 @@ private static HttpResponseMessage BuildRoleAssignmentsResponse(string scope, st private static HttpResponseMessage PolicyResponse(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body) }; + [Theory] + // Userinfo trick: `management.azure.com` becomes the username and the real host is the attacker's. + [InlineData("@evil.example/x")] + [InlineData("evil.example/x")] + [InlineData("//evil.example/x")] + [InlineData("https://evil.example/x")] + [InlineData("/subscriptions/not-a-guid/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p?x=1")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p#frag")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p/../../x")] + public async Task GetEnterprisePolicySystemIdAsync_WhenArmIdIsNotAnEnterprisePolicyPath_RejectsWithoutCalling( + string policyArmId) + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.GetEnterprisePolicySystemIdAsync(policyArmId, TenantId); + + result.Should().BeNull(); + handler.RequestCount.Should().Be( + 0, + because: "the ARM bearer token is a default header, so a redirected host would receive it"); + } + [Fact] public async Task GetEnterprisePolicySystemIdAsync_When200_ReturnsSystemId() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs index df602d60..b13cc86b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs @@ -49,7 +49,19 @@ private static VNetLinkService CreateService( HttpMessageHandler handler, ArmApiService? arm = null, IAuthenticationService? auth = null) => - new(NullLogger.Instance, auth ?? FakeAuth(), arm ?? FakeArm(), "prod", handler); + new( + NullLogger.Instance, + auth ?? FakeAuth(), + arm ?? FakeArm(), + "prod", + handler, + NoLoginHint); + + /// + /// Stands in for the real resolver so the tests never shell out to `az account show`. + /// The production default caches in a static field shared with AzCliHelperTests. + /// + private static Task NoLoginHint() => Task.FromResult(null); private static HttpResponseMessage StatusResponse( HttpStatusCode code, @@ -74,6 +86,8 @@ private static HttpResponseMessage StatusResponse( [InlineData("Running", true)] [InlineData("running", true)] [InlineData("RUNNING", true)] + [InlineData("NotStarted", true)] + [InlineData("notstarted", true)] [InlineData("Linked", false)] [InlineData("NotLinked", false)] [InlineData("Failed", false)] @@ -244,7 +258,7 @@ public async Task UnlinkAsync_PostsToUnlinkWithNoBody() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotLinked")); var svc = CreateService(handler); - var result = await svc.UnlinkAsync(); + var result = await svc.UnlinkAsync(TenantId); result.Should().NotBeNull(); result!.Status.Should().Be("NotLinked"); @@ -267,7 +281,7 @@ public async Task UnlinkAsync_WhenPlatformFails_ReturnsNull() }); var svc = CreateService(handler); - var result = await svc.UnlinkAsync(); + var result = await svc.UnlinkAsync(TenantId); result.Should().BeNull(); } @@ -282,7 +296,7 @@ public async Task GetStatusAsync_WithoutOperationId_OmitsTheQueryString() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", policyArmId: PolicyArmId)); var svc = CreateService(handler); - var result = await svc.GetStatusAsync(); + var result = await svc.GetStatusAsync(TenantId); result.Should().NotBeNull(); result!.Status.Should().Be("Linked"); @@ -303,7 +317,7 @@ public async Task GetStatusAsync_WithOperationId_EscapesItIntoTheQueryString() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Running", "a b/c")); var svc = CreateService(handler); - await svc.GetStatusAsync("a b/c"); + await svc.GetStatusAsync(TenantId, "a b/c"); captured!.RequestUri!.Query.Should().Be("?operationId=a%20b%2Fc"); } @@ -315,7 +329,7 @@ public async Task GetStatusAsync_WhenBodyEmpty_ReturnsEmptyStatus() handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }); var svc = CreateService(handler); - var result = await svc.GetStatusAsync(); + var result = await svc.GetStatusAsync(TenantId); result.Should().NotBeNull(); result!.Status.Should().BeNull(); @@ -331,7 +345,7 @@ public async Task GetStatusAsync_SurfacesTheFailureReason() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Failed", OperationId, reason: "Region mismatch.")); var svc = CreateService(handler); - var result = await svc.GetStatusAsync(OperationId); + var result = await svc.GetStatusAsync(TenantId, OperationId); result.Should().NotBeNull(); result!.Status.Should().Be("Failed"); @@ -349,7 +363,7 @@ public async Task WaitForCompletionAsync_ReturnsAsSoonAsTheOperationSettles() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", OperationId)); var svc = CreateService(handler); - var result = await svc.WaitForCompletionAsync(OperationId, TimeSpan.FromMinutes(5)); + var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.FromMinutes(5)); result.Should().NotBeNull(); result!.Status.Should().Be("Linked"); @@ -364,7 +378,7 @@ public async Task WaitForCompletionAsync_WhenStillRunningAndBudgetExhausted_Retu var svc = CreateService(handler); // A zero budget cannot fit another poll interval, so the first read is also the last. - var result = await svc.WaitForCompletionAsync(OperationId, TimeSpan.Zero); + var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.Zero); result.Should().NotBeNull(); result!.Status.Should().Be("Running"); @@ -382,7 +396,7 @@ public async Task WaitForCompletionAsync_WhenStatusCannotBeRead_ReturnsNull() }); var svc = CreateService(handler); - var result = await svc.WaitForCompletionAsync(OperationId, TimeSpan.FromMinutes(5)); + var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.FromMinutes(5)); result.Should().BeNull(); handler.RequestCount.Should().Be(1, because: "an unreadable status is terminal for the wait"); @@ -397,11 +411,73 @@ public async Task WaitForCompletionAsync_WhenOperationIdBlank_Throws(string? ope using var handler = new TestHttpMessageHandler(); var svc = CreateService(handler); - var act = async () => await svc.WaitForCompletionAsync(operationId!, TimeSpan.FromMinutes(5)); + var act = async () => await svc.WaitForCompletionAsync(TenantId, operationId!, TimeSpan.FromMinutes(5)); await act.Should().ThrowAsync(); } + // ────────────────────────── Token acquisition ────────────────────────────── + + [Fact] + public async Task LinkAsync_AcquiresTheAgent365TokenForTheRequestedTenant() + { + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); + var svc = CreateService(handler, auth: auth); + + await svc.LinkAsync(PolicyArmId, swap: false, "contoso-tenant"); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + "contoso-tenant", + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task UnlinkAsync_AcquiresTheAgent365TokenForTheRequestedTenant() + { + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotLinked")); + var svc = CreateService(handler, auth: auth); + + await svc.UnlinkAsync("contoso-tenant"); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + "contoso-tenant", + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetStatusAsync_AcquiresTheAgent365TokenForTheRequestedTenant() + { + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); + var svc = CreateService(handler, auth: auth); + + await svc.GetStatusAsync("contoso-tenant"); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + "contoso-tenant", + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + // ───────────────────────────── Constructor guards ────────────────────────── [Fact] From 71a6570e12d2db2a509dbefddda0962567bec5f0 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Wed, 23 Sep 2026 21:37:16 -0700 Subject: [PATCH 5/9] fix: bound the wait ceiling, propagate cancellation, correct the az login prerequisite The --wait ceiling only bounded the gap between completed polls, so a poll starting just inside the budget could run to the HttpClient's own timeout and overshoot by minutes. The ceiling is now armed on the token each request is made with; a timeout mid-request reports the last known state, while a caller's Ctrl+C still propagates. ArmApiService's broad catch swallowed the OperationCanceledException that RetryHelper deliberately rethrows, so Ctrl+C during the policy read surfaced as "could not read the policy" and link carried on as though the policy did not exist. Both test helpers configured GetAccessTokenAsync without a matcher for its 8th parameter, the CancellationToken, pinning the setup to ct == default. Any call carrying a real token missed the setup and returned null, which the services report as a failed token acquisition -- so a test could not exercise any cancellation path at all. This is why the two new cancellation tests initially failed for the wrong reason. docs: the az login prerequisite claimed it was used "only to read the enterprise policy". It is actually the source of two defaults, the tenant and the signed-in account, and --tenant-id overrides only the first. Tokens are never borrowed from Azure CLI -- both the ARM read and the Agent 365 call acquire their own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands/network.md | 5 +- .../Services/ArmApiService.cs | 7 +++ .../Services/VNetLinkService.cs | 23 ++++++- .../Services/ArmApiServiceTests.cs | 35 ++++++++++- .../Services/VNetLinkServiceTests.cs | 60 ++++++++++++++++++- 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/commands/network.md b/docs/commands/network.md index 0fdcb746..b4757275 100644 --- a/docs/commands/network.md +++ b/docs/commands/network.md @@ -23,7 +23,10 @@ subnets, delegate them to `Microsoft.PowerPlatform/enterprisePolicies`, and crea - **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects anyone else. -- An active `az login` session in the same tenant. Used only to read the enterprise policy. +- An active `az login` session. It supplies two defaults: the tenant to operate on, and the + signed-in account to authenticate as. `--tenant-id` overrides the first; the account still comes + from `az login`. Tokens are not borrowed from Azure CLI -- both the ARM policy read and the + Agent 365 call acquire their own tokens through the CLI's sign-in. - A NetworkInjection enterprise policy already created by `New-SubnetInjectionEnterprisePolicy`, with subnets delegated to `Microsoft.PowerPlatform/enterprisePolicies`. - Public cloud only. Sovereign clouds are not supported. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs index 06d5efd2..8a599e6f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs @@ -342,6 +342,13 @@ private async Task EnsureArmHeadersAsync(string tenantId, CancellationToke _logger.LogDebug("Resolved enterprise policy systemId"); return value; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // RetryHelper rethrows cancellation deliberately. Swallowing it here would report + // Ctrl+C as "policy not found" and let the caller carry on as if the read had + // simply come back empty. + throw; + } catch (Exception ex) { if (NetworkHelper.IsConnectionResetByProxy(ex)) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs index 047d976c..de56537c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs @@ -119,12 +119,31 @@ public VNetLinkService( // Wall clock, not summed sleeps: each status call costs real time, and a caller who asked // for five minutes should not wait eight because the service was slow. + // + // The stopwatch alone only bounds the gap between completed polls. A poll that starts just + // inside the ceiling can still run to the HttpClient's own timeout, overshooting by minutes, + // so the ceiling is also armed on the token every request is made with. + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + var stopwatch = Stopwatch.StartNew(); VNetStatusResponse? last = null; while (true) { - last = await GetStatusAsync(tenantId, operationId, cancellationToken); + try + { + last = await GetStatusAsync(tenantId, operationId, timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The ceiling elapsed mid-request. That is a timeout, not a failure: report the + // last known state, exactly as the pre-sleep check below does. + _logger.LogInformation( + "Stopped waiting after {Elapsed:0}s. The operation is still running.", + stopwatch.Elapsed.TotalSeconds); + return last; + } if (last == null || !IsRunning(last.Status)) return last; @@ -133,6 +152,8 @@ public VNetLinkService( return last; _logger.LogInformation("Still running... ({Elapsed:0}s elapsed)", stopwatch.Elapsed.TotalSeconds); + // The pre-sleep check above guarantees this delay finishes inside the ceiling, so it + // waits on the caller's token only -- the timeout can't fire here. await Task.Delay(PollInterval, cancellationToken); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs index c6dfa2c2..3fbead48 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs @@ -29,8 +29,12 @@ public class ArmApiServiceTests private static IAuthenticationService FakeAuth() { var mock = Substitute.For(); + + // The 8th parameter is the CancellationToken. Without a matcher the setup is pinned to + // ct == default, so any call carrying a real token misses it and the service reports a + // failed token acquisition instead of doing the work under test. mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any(), Arg.Any()) + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult("fake-arm-token")); return mock; } @@ -430,6 +434,35 @@ public async Task GetEnterprisePolicySystemIdAsync_WhenSystemIdMissing_ReturnsNu result.Should().BeNull(because: "a policy without a systemId is not yet usable for linking"); } + [Fact] + public async Task GetEnterprisePolicySystemIdAsync_WhenCallerCancels_PropagatesRatherThanReportingNoPolicy() + { + // RetryHelper rethrows cancellation on purpose. Folding it into the broad catch would + // report Ctrl+C as "could not read the policy" and let link carry on as if the policy + // simply did not exist. + using var handler = new SlowHttpMessageHandler(TimeSpan.FromSeconds(30)); + var svc = CreateService(handler); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + var act = async () => await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId, cts.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// Holds each request open until the request's own token is cancelled, so a test can observe + /// what the service does with a cancellation raised mid-call. + /// + private sealed class SlowHttpMessageHandler(TimeSpan delay) : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(delay, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; + } + } + [Fact] public async Task GetEnterprisePolicySystemIdAsync_WhenHttpThrows_ReturnsNull() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs index b13cc86b..bc7783b8 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.Net; using System.Text.Json; using FluentAssertions; @@ -31,8 +32,12 @@ public class VNetLinkServiceTests private static IAuthenticationService FakeAuth(string token = "fake-a365-token") { var mock = Substitute.For(); + + // The 8th parameter is the CancellationToken. Omitting a matcher for it pins the setup to + // ct == default, so any call carrying a real token -- a caller's, or the wait ceiling's -- + // silently misses and returns null, which the service reports as a failed token acquisition. mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any(), Arg.Any()) + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(token)); return mock; } @@ -416,6 +421,59 @@ public async Task WaitForCompletionAsync_WhenOperationIdBlank_Throws(string? ope await act.Should().ThrowAsync(); } + [Fact] + public async Task WaitForCompletionAsync_WhenCeilingElapsesDuringAPoll_StopsWaitingOnTheInFlightCall() + { + // The pre-sleep stopwatch check only bounds the gap between completed polls. Without the + // ceiling armed on the request's own token, a poll that starts inside the budget runs to + // the HttpClient's timeout -- minutes past what the caller asked for. + using var handler = new SlowHttpMessageHandler( + TimeSpan.FromSeconds(30), + () => StatusResponse(HttpStatusCode.OK, "Running", OperationId)); + var svc = CreateService(handler); + + var stopwatch = Stopwatch.StartNew(); + var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.FromMilliseconds(200)); + stopwatch.Stop(); + + result.Should().BeNull(because: "the ceiling elapsed before any status was read"); + stopwatch.Elapsed.Should().BeLessThan( + TimeSpan.FromSeconds(10), + because: "the wait must abandon the in-flight request rather than block on it"); + } + + [Fact] + public async Task WaitForCompletionAsync_WhenCallerCancels_PropagatesRatherThanReportingATimeout() + { + // The timeout and a Ctrl+C both surface as OperationCanceledException. Only the timeout is + // swallowed into "still running"; a caller cancel has to reach the caller. + using var handler = new SlowHttpMessageHandler( + TimeSpan.FromSeconds(30), + () => StatusResponse(HttpStatusCode.OK, "Running", OperationId)); + var svc = CreateService(handler); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + var act = async () => await svc.WaitForCompletionAsync( + TenantId, OperationId, TimeSpan.FromMinutes(5), cts.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// Holds each request open for unless the request's own token is + /// cancelled first, so a test can tell "abandoned the call" from "waited for the response". + /// + private sealed class SlowHttpMessageHandler(TimeSpan delay, Func responseFactory) + : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(delay, cancellationToken); + return responseFactory(); + } + } + // ────────────────────────── Token acquisition ────────────────────────────── [Fact] From ae03dfea4942e53948acede1b27b7952598d2976 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Wed, 23 Sep 2026 21:39:46 -0700 Subject: [PATCH 6/9] fix: resolve the az account once per run and bound the GSA wait ceiling Every convergence poll re-entered SendAsync, which shells out to `az account show` and returns null on any CLI hiccup. A transient failure part-way through --wait therefore aborted with "could not determine your Azure tenant" even though the tenant had been resolved successfully moments earlier. The account is now resolved once and reused; a failed resolution is not cached, so an admin who runs `az login` after the first attempt does not have to restart the process. WaitForStatusAsync had the same unbounded in-flight poll as the vnet wait: the stopwatch only bounded the gap between completed polls, so a poll starting inside the budget could run to the HttpClient's timeout. The ceiling is now armed on the request token, and a caller's Ctrl+C is still distinguished from a timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/GsaService.cs | 36 +++++- .../Services/GsaServiceTests.cs | 104 +++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs index 1aee0e7c..a0abd85d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs @@ -35,6 +35,15 @@ public class GsaService : IGsaService private readonly string _environment; private readonly HttpMessageHandler? _handler; + /// + /// The az account, resolved once and reused. re-enters + /// for every poll, and each resolution shells out to + /// az account show, which returns null on any CLI hiccup -- so without this a + /// transient failure part-way through a wait aborts the wait with "could not determine + /// your Azure tenant" even though the tenant was known all along. + /// + private AzureAccountInfo? _account; + public GsaService( ILogger logger, IAuthenticationService authService, @@ -77,11 +86,31 @@ public GsaService( // Wall clock, not summed sleeps: each status call costs real time, and a caller who asked // for five minutes should not wait eight because the service was slow. + // + // The stopwatch alone only bounds the gap between completed polls. A poll that starts just + // inside the ceiling can still run to the HttpClient's own timeout, overshooting by + // minutes, so the ceiling is also armed on the token every request is made with. + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + var stopwatch = Stopwatch.StartNew(); + GsaStatusResponse? last = null; while (true) { - var last = await GetStatusAsync(cancellationToken); + try + { + last = await GetStatusAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The ceiling elapsed mid-request. That is a timeout, not a failure: report the + // last known state, exactly as the pre-sleep check below does. + _logger.LogInformation( + "Stopped waiting after {Elapsed:0}s. The change is still being applied.", + stopwatch.Elapsed.TotalSeconds); + return last; + } if (last == null || string.Equals(last.Status, expectedStatus, StringComparison.OrdinalIgnoreCase)) return last; @@ -90,6 +119,9 @@ public GsaService( return last; _logger.LogInformation("Still applying... ({Elapsed:0}s elapsed)", stopwatch.Elapsed.TotalSeconds); + + // The pre-sleep check above guarantees this delay finishes inside the ceiling, so it + // waits on the caller's token only -- the timeout can't fire here. await Task.Delay(PollInterval, cancellationToken); } } @@ -113,7 +145,7 @@ public GsaService( // "common", and WAM silently returns the Windows account even when a login hint names // a different one — so a tenant-wide setting would be changed on the wrong tenant. // Passing the tenant also arms the mismatch self-heal in AuthenticationService. - var account = await _azureCliService.GetCurrentAccountAsync(); + var account = _account ??= await _azureCliService.GetCurrentAccountAsync(); if (account is null || string.IsNullOrWhiteSpace(account.TenantId)) { _logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again."); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs index 45236c9f..818701a5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using System.Net; using System.Text.Json; using FluentAssertions; @@ -22,8 +23,12 @@ public class GsaServiceTests private static IAuthenticationService FakeAuth(string token = "fake-a365-token") { var mock = Substitute.For(); + + // The 8th parameter is the CancellationToken. Omitting a matcher for it pins the setup to + // ct == default, so any call carrying a real token -- a caller's, or the wait ceiling's -- + // silently misses and returns null, which the service reports as a failed token acquisition. mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any(), Arg.Any()) + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(token)); return mock; } @@ -421,4 +426,101 @@ public async Task WaitForStatusAsync_WhenTheBudgetCannotCoverAnotherPoll_Returns result.Reason.Should().BeNull(); handler.RequestCount.Should().Be(1); } + + [Fact] + public async Task WaitForStatusAsync_WhenCeilingElapsesDuringAPoll_StopsWaitingOnTheInFlightCall() + { + // The pre-sleep stopwatch check only bounds the gap between completed polls. Without the + // ceiling armed on the request's own token, a poll that starts inside the budget runs to + // the HttpClient's timeout -- minutes past what the caller asked for. + using var handler = new SlowHttpMessageHandler(TimeSpan.FromSeconds(30)); + var svc = CreateService(handler); + + var stopwatch = Stopwatch.StartNew(); + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMilliseconds(200)); + stopwatch.Stop(); + + result.Should().BeNull(because: "the ceiling elapsed before any status was read"); + stopwatch.Elapsed.Should().BeLessThan( + TimeSpan.FromSeconds(10), + because: "the wait must abandon the in-flight request rather than block on it"); + } + + [Fact] + public async Task WaitForStatusAsync_WhenCallerCancels_PropagatesRatherThanReportingATimeout() + { + // The timeout and a Ctrl+C both surface as OperationCanceledException. Only the timeout is + // swallowed into "still applying"; a caller cancel has to reach the caller. + using var handler = new SlowHttpMessageHandler(TimeSpan.FromSeconds(30)); + var svc = CreateService(handler); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + var act = async () => await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(5), cts.Token); + + await act.Should().ThrowAsync(); + } + + // ───────────────────── az account resolution ──────────────────────────────── + + [Fact] + public async Task WaitForStatusAsync_ResolvesTheAzAccountOnceAcrossEveryPoll() + { + // Each resolution shells out to `az account show` and returns null on any CLI hiccup, so + // re-resolving per poll turns a transient failure mid-wait into "could not determine your + // Azure tenant" even though the tenant was known from the first call. + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled", pending: true)); + var azureCli = FakeAzureCli(); + var svc = CreateService(handler, azureCli: azureCli); + + await svc.GetStatusAsync(); + await svc.WaitForStatusAsync("Enabled", TimeSpan.FromSeconds(1)); + + await azureCli.Received(1).GetCurrentAccountAsync(); + } + + [Fact] + public async Task GetStatusAsync_WhenTheAccountIsUnavailable_RetriesOnTheNextCall() + { + // The cache must not pin a failure: an admin who runs `az login` after the first attempt + // should not have to restart the process. + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var azureCli = Substitute.For(); + azureCli.GetCurrentAccountAsync().Returns( + Task.FromResult(null), + Task.FromResult(new AzureAccountInfo + { + TenantId = "11111111-1111-1111-1111-111111111111", + User = new AzureUser { Name = "admin@contoso.onmicrosoft.com" }, + })); + var svc = CreateService(handler, azureCli: azureCli); + + var first = await svc.GetStatusAsync(); + var second = await svc.GetStatusAsync(); + + first.Should().BeNull(because: "no az account means no tenant to authenticate against"); + second.Should().NotBeNull(); + second!.Status.Should().Be("Enabled"); + second.Pending.Should().BeFalse(); + second.Reason.Should().BeNull(); + await azureCli.Received(2).GetCurrentAccountAsync(); + } + + /// + /// Holds each request open until the request's own token is cancelled, so a test can tell + /// "abandoned the call" from "waited for the response". + /// + private sealed class SlowHttpMessageHandler(TimeSpan delay) : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + await Task.Delay(delay, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { status = "Enabled", pending = false })), + }; + } + } } From d923c2a770a739464b05bce3c444519457373a1e Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Wed, 23 Sep 2026 21:49:21 -0700 Subject: [PATCH 7/9] Remove the VNet change set so this PR carries only GSA This branch was cut to include PR #494's virtual network work, so the two PRs duplicated roughly 1400 lines and every VNet review finding had to be answered twice. GSA never depended on any of it: GsaService talks to the platform directly and shares only the tenant resolution and confirmation helpers in NetworkCommand, which both features need. Removed VNetLinkService, IVNetLinkService, VNetModels and their tests, reverted ArmApiService and its tests to main, dropped the vnet subcommand tree along with ReportAsync and LogStatus, and removed the vnet entries from the CHANGELOG and the docs index. NetworkCommand.CreateCommand and its test helper lose the IVNetLinkService parameter. The two branches now both define the network root command and the shared helpers, so whichever merges second will conflict there. That is a smaller price than reviewing the same code on two PRs. 2063 passed, 0 failed, 12 skipped. --- CHANGELOG.md | 1 - docs/commands/README.md | 5 +- docs/commands/network-gsa.md | 2 +- docs/commands/network.md | 111 ---- .../Commands/NetworkCommand.cs | 275 +-------- .../Models/VNetModels.cs | 77 --- .../Program.cs | 8 +- .../Services/ArmApiService.cs | 121 ---- .../Services/IVNetLinkService.cs | 64 -- .../Services/VNetLinkService.cs | 277 --------- .../Commands/NetworkCommandTests.cs | 426 +------------ .../Services/ArmApiServiceTests.cs | 206 +------ .../Services/VNetLinkServiceTests.cs | 561 ------------------ 13 files changed, 10 insertions(+), 2124 deletions(-) delete mode 100644 docs/commands/network.md delete mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs delete mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs delete mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs delete mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index bad6dfa4..9834b459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,6 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g ### Added - `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md) (#497). -- `a365 network vnet link|unlink|status` — links an Azure virtual network to Agent 365 through a Power Platform NetworkInjection enterprise policy, replacing `Enable-SubnetInjection` (#494). Requires Global Administrator or Power Platform Administrator. See [docs/commands/network.md](docs/commands/network.md). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. diff --git a/docs/commands/README.md b/docs/commands/README.md index 8ec8f708..29dcc110 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -27,10 +27,7 @@ There is reference documentation for each command. | [develop-mcp list-servers](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-servers) | List MCP servers in a specific Dataverse environment. | | [develop-mcp publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-publish) | Publish an MCP server to a Dataverse environment. | | [develop-mcp unpublish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-unpublish) | Unpublish an MCP server from a Dataverse environment. | -| [network](network.md) | Configure tenant networking for Agent 365. | -| [network vnet link](network.md#link) | Link a NetworkInjection enterprise policy to your Agent 365 environment. | -| [network vnet unlink](network.md#unlink) | Remove the virtual network link from your Agent 365 environment. | -| [network vnet status](network.md#status) | Show whether a virtual network policy is linked to your Agent 365 environment. | +| [network](network-gsa.md) | Configure tenant networking for Agent 365. | | [network gsa enable](network-gsa.md#enable-and-disable) | Turn Global Secure Access on for your Agent 365 environment. | | [network gsa disable](network-gsa.md#enable-and-disable) | Turn Global Secure Access off for your Agent 365 environment. | | [network gsa status](network-gsa.md#status) | Show whether Global Secure Access is on for your Agent 365 environment. | diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md index fb616d74..ffdc91c5 100644 --- a/docs/commands/network-gsa.md +++ b/docs/commands/network-gsa.md @@ -17,7 +17,7 @@ platform to apply the change against the environment it resolves for your tenant - An `az login` to the tenant you intend to configure. - Public cloud only. Sovereign clouds are not supported. -Nothing is read from Azure — unlike `a365 network vnet` — but the `az login` still matters: it is +Nothing is read from Azure, but the `az login` still matters: it is what selects the tenant. The what selects the tenant. The commands authenticate against the tenant and account of your current `az account show`, so `az login --tenant ` is how you choose which tenant to configure when you have more than one. Without an explicit tenant the Windows broker silently returns whichever diff --git a/docs/commands/network.md b/docs/commands/network.md deleted file mode 100644 index b4757275..00000000 --- a/docs/commands/network.md +++ /dev/null @@ -1,111 +0,0 @@ -# `a365 network vnet` - -Links an Azure virtual network to Agent 365 via a Power Platform **NetworkInjection enterprise -policy**, without needing the id of the Power Platform environment. - -## Why this command exists - -The documented subnet-injection flow -([Set up virtual network support](https://learn.microsoft.com/power-platform/admin/vnet-support-setup-configure)) -ends with `Enable-SubnetInjection` from the `Microsoft.PowerPlatform.EnterprisePolicies` module, -which takes an `-environmentId`. Agent 365 provisions a managed Power Platform environment for the -tenant and does not publish its id, so that final step cannot be run. - -`a365 network vnet` replaces only that last step. The CLI reads the policy's `systemId` from Azure -using your existing `az login`, then asks the Agent 365 platform to perform the link against the -environment it resolves for your tenant. - -Everything before the final step is unchanged — keep using the PowerShell module to create the -subnets, delegate them to `Microsoft.PowerPlatform/enterprisePolicies`, and create the policy with -`New-SubnetInjectionEnterprisePolicy`. - -## Prerequisites - -- **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects - anyone else. -- An active `az login` session. It supplies two defaults: the tenant to operate on, and the - signed-in account to authenticate as. `--tenant-id` overrides the first; the account still comes - from `az login`. Tokens are not borrowed from Azure CLI -- both the ARM policy read and the - Agent 365 call acquire their own tokens through the CLI's sign-in. -- A NetworkInjection enterprise policy already created by `New-SubnetInjectionEnterprisePolicy`, - with subnets delegated to `Microsoft.PowerPlatform/enterprisePolicies`. -- Public cloud only. Sovereign clouds are not supported. - -## Subcommands - -| Command | Description | -| --- | --- | -| `a365 network vnet link` | Link a NetworkInjection enterprise policy to the tenant's Agent 365 environment. | -| `a365 network vnet unlink` | Remove the virtual network link. | -| `a365 network vnet status` | Show the current link, or check a running operation. | - -### `link` - -```bash -a365 network vnet link --policy-arm-id [--swap] [--tenant-id ] [--wait] [--yes] -``` - -| Option | Description | -| --- | --- | -| `--policy-arm-id`, `-p` | **Required.** ARM resource id of the policy, as returned by `New-SubnetInjectionEnterprisePolicy`. | -| `--swap` | Replace an existing link to a *different* policy. Without it, a different existing link is reported as a conflict instead of being silently replaced. | -| `--tenant-id` | Tenant to authenticate against. Defaults to the tenant of your current `az login`. | -| `--wait` | Poll until the operation settles instead of returning an operation id. | -| `--yes`, `-y` | Skip the confirmation prompt shown for `--swap`. | - -Linking the policy that is already linked is a no-op and succeeds without `--swap`. - -### `unlink` - -```bash -a365 network vnet unlink [--tenant-id ] [--wait] [--yes] -``` - -Unlink needs no policy id — the platform remembers which policy it linked. It prompts before -removing the link; pass `--yes` in automation. - -### `status` - -```bash -a365 network vnet status [--operation-id ] [--tenant-id ] -``` - -Without `--operation-id`, reports the environment's current link. With one, reports that specific -operation. - -## Statuses and exit codes - -| Status | Meaning | -| --- | --- | -| `Linked` | A policy is linked; `Policy` names it. | -| `NotLinked` | No policy is linked. | -| `Running` / `NotStarted` | The operation is still in flight; `Operation` is the handle to poll. | -| `Failed` | The operation failed; `Reason` explains why. | - -Exit code is `1` on `Failed` or on any request error, and `0` otherwise — including a still-running -operation, which is a legitimate outcome when `--wait` is not passed. - -## Typical flow - -```bash -# 1. Create the policy with the PowerShell module (unchanged). -./SubnetInjection/NewSubnetInjectionEnterprisePolicy.ps1 ` - -subscription -resourceGroup -enterprisePolicyName ` - -enterprisePolicyLocation -virtualNetworkId -subnetName - -# 2. Link it — this replaces Enable-SubnetInjection. -a365 network vnet link --policy-arm-id /subscriptions//resourceGroups//providers/Microsoft.PowerPlatform/enterprisePolicies/ --wait - -# 3. Confirm. -a365 network vnet status -``` - -## Troubleshooting - -| Symptom | Cause | -| --- | --- | -| `Could not determine your Azure tenant` | No `az login` session. Run `az login`, or pass `--tenant-id`. | -| `--tenant-id was supplied but is empty` | `--tenant-id` was passed with a blank value. Pass a tenant id, or omit the option entirely. | -| `403` from the platform | Caller is not a Global or Power Platform Administrator, or the CLI app lacks consent for the `AgentTools.VNet.*` scopes. | -| Conflict reported on `link` | A *different* policy is already linked. Re-run with `--swap`, or `unlink` first. | -| Policy read fails | The policy ARM id is wrong, or your `az login` identity cannot read it. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs index 893f59ee..89b830db 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -13,36 +13,25 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; /// /// Tenant network configuration for Agent 365. /// -/// Subnet injection normally ends with Enable-SubnetInjection from the -/// Microsoft.PowerPlatform.EnterprisePolicies module, which needs the id of the Power Platform -/// environment being linked. Agent 365 does not publish that id, so these subcommands ask the -/// platform to perform the link against the environment it resolves for your tenant. +/// Global Secure Access is normally set per Power Platform environment, which needs the id of the +/// environment being configured. Agent 365 does not publish that id, so these subcommands ask the +/// platform to apply the setting to the environment it resolves for your tenant. /// public static class NetworkCommand { private static readonly TimeSpan DefaultWaitTimeout = TimeSpan.FromMinutes(10); /// - /// Creates the network command and its vnet subcommand tree. + /// Creates the network command and its gsa subcommand tree. /// public static Command CreateCommand( ILogger logger, - IVNetLinkService vnetLinkService, IAzureCliService azureCliService, IGsaService gsaService, IConfirmationProvider confirmationProvider) { var networkCommand = new Command(CommandNames.Network, "Configure tenant networking for Agent 365"); - var vnetCommand = new Command( - "vnet", - "Link an Azure virtual network enterprise policy to your Agent 365 environment. " + - "Requires the Global Administrator or Power Platform Administrator role."); - - vnetCommand.AddCommand(CreateLinkSubcommand(logger, vnetLinkService, azureCliService, confirmationProvider)); - vnetCommand.AddCommand(CreateUnlinkSubcommand(logger, vnetLinkService, azureCliService, confirmationProvider)); - vnetCommand.AddCommand(CreateStatusSubcommand(logger, vnetLinkService, azureCliService)); - var gsaCommand = new Command( "gsa", "Turn Global Secure Access on or off for your Agent 365 environment. " + @@ -54,7 +43,6 @@ public static Command CreateCommand( logger, gsaService, azureCliService, confirmationProvider, enabled: false)); gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService)); - networkCommand.AddCommand(vnetCommand); networkCommand.AddCommand(gsaCommand); return networkCommand; } @@ -115,241 +103,6 @@ internal static async Task ConfirmChangeAsync( $"{action} for tenant {tenantId}. This changes networking for every Agent 365 agent in the tenant. Continue?"); } - private static Command CreateLinkSubcommand( - ILogger logger, - IVNetLinkService vnetLinkService, - IAzureCliService azureCliService, - IConfirmationProvider confirmationProvider) - { - var command = new Command( - "link", - "Link a NetworkInjection enterprise policy to your Agent 365 environment. " + - "Create the policy first with New-SubnetInjectionEnterprisePolicy; this replaces the " + - "Enable-SubnetInjection step that requires an environment id."); - - var policyArmIdOption = new Option( - ["--policy-arm-id", "-p"], - "ARM resource id of the NetworkInjection enterprise policy, as returned by " + - "New-SubnetInjectionEnterprisePolicy") - { - IsRequired = true, - }; - - var swapOption = new Option( - "--swap", - "Replace an existing link to a different policy. Without this, an existing different " + - "link is reported as a conflict rather than silently replaced."); - - var tenantIdOption = new Option( - "--tenant-id", - "Tenant to authenticate against for the Azure policy read. Defaults to the tenant of " + - "your current az login."); - - var waitOption = new Option( - "--wait", - "Keep polling until the link settles, instead of returning an operation id."); - - var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); - - var yesOption = new Option( - ["--yes", "-y"], - "Skip the confirmation prompt shown when --swap would replace an existing link."); - - command.AddOption(policyArmIdOption); - command.AddOption(swapOption); - command.AddOption(tenantIdOption); - command.AddOption(waitOption); - command.AddOption(yesOption); - command.AddOption(verboseOption); - - command.SetHandler(async (InvocationContext context) => - { - var policyArmId = context.ParseResult.GetValueForOption(policyArmIdOption)!; - var swap = context.ParseResult.GetValueForOption(swapOption); - var tenantIdOptionValue = context.ParseResult.GetValueForOption(tenantIdOption); - var wait = context.ParseResult.GetValueForOption(waitOption); - var yes = context.ParseResult.GetValueForOption(yesOption); - var ct = context.GetCancellationToken(); - - var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOptionValue); - if (tenantId == null) - { - context.ExitCode = 1; - return; - } - - // Only --swap needs confirming: without it an existing different link is reported as a - // conflict rather than replaced, so the command is already non-destructive. - if (swap && !await ConfirmChangeAsync( - confirmationProvider, yes, "Replace the existing virtual network link", tenantId)) - { - logger.LogInformation("Cancelled."); - context.ExitCode = 1; - return; - } - - var result = await vnetLinkService.LinkAsync(policyArmId, swap, tenantId, ct); - context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Link", tenantId, ct); - }); - - return command; - } - - private static Command CreateUnlinkSubcommand( - ILogger logger, - IVNetLinkService vnetLinkService, - IAzureCliService azureCliService, - IConfirmationProvider confirmationProvider) - { - var command = new Command( - "unlink", - "Remove the virtual network link from your Agent 365 environment."); - - var waitOption = new Option( - "--wait", - "Keep polling until the unlink settles, instead of returning an operation id."); - - var tenantIdOption = new Option( - "--tenant-id", - "Tenant to authenticate against. Defaults to the tenant of your current az login."); - - var yesOption = new Option( - ["--yes", "-y"], - "Skip the confirmation prompt."); - - var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); - - command.AddOption(waitOption); - command.AddOption(tenantIdOption); - command.AddOption(yesOption); - command.AddOption(verboseOption); - - command.SetHandler(async (InvocationContext context) => - { - var wait = context.ParseResult.GetValueForOption(waitOption); - var tenantIdOptionValue = context.ParseResult.GetValueForOption(tenantIdOption); - var yes = context.ParseResult.GetValueForOption(yesOption); - var ct = context.GetCancellationToken(); - - var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOptionValue); - if (tenantId == null) - { - context.ExitCode = 1; - return; - } - - if (!await ConfirmChangeAsync( - confirmationProvider, yes, "Remove the virtual network link", tenantId)) - { - logger.LogInformation("Cancelled."); - context.ExitCode = 1; - return; - } - - var result = await vnetLinkService.UnlinkAsync(tenantId, ct); - context.ExitCode = await ReportAsync(logger, vnetLinkService, result, wait, "Unlink", tenantId, ct); - }); - - return command; - } - - private static Command CreateStatusSubcommand( - ILogger logger, - IVNetLinkService vnetLinkService, - IAzureCliService azureCliService) - { - var command = new Command( - "status", - "Show whether a virtual network policy is linked to your Agent 365 environment."); - - var operationIdOption = new Option( - "--operation-id", - "Operation handle returned by a link or unlink that was still running."); - - var tenantIdOption = new Option( - "--tenant-id", - "Tenant to authenticate against. Defaults to the tenant of your current az login."); - - var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); - - command.AddOption(operationIdOption); - command.AddOption(tenantIdOption); - command.AddOption(verboseOption); - - command.SetHandler(async (InvocationContext context) => - { - var operationId = context.ParseResult.GetValueForOption(operationIdOption); - var tenantIdOptionValue = context.ParseResult.GetValueForOption(tenantIdOption); - var ct = context.GetCancellationToken(); - - var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOptionValue); - if (tenantId == null) - { - context.ExitCode = 1; - return; - } - - var status = await vnetLinkService.GetStatusAsync(tenantId, operationId, ct); - if (status == null) - { - context.ExitCode = 1; - return; - } - - LogStatus(logger, status); - context.ExitCode = string.Equals(status.Status, "Failed", StringComparison.OrdinalIgnoreCase) ? 1 : 0; - }); - - return command; - } - - /// - /// Renders the outcome of a link or unlink, optionally waiting for a running operation first, - /// and maps it to a process exit code. - /// - internal static async Task ReportAsync( - ILogger logger, - IVNetLinkService vnetLinkService, - VNetStatusResponse? result, - bool wait, - string operationLabel, - string tenantId, - CancellationToken cancellationToken) - { - if (result == null) - { - return 1; - } - - if (wait && VNetLinkService.IsRunning(result.Status) && !string.IsNullOrWhiteSpace(result.OperationId)) - { - logger.LogInformation("{Operation} is running. Waiting for it to settle...", operationLabel); - result = await vnetLinkService.WaitForCompletionAsync(tenantId, result.OperationId, DefaultWaitTimeout, cancellationToken); - - if (result == null) - { - return 1; - } - } - - LogStatus(logger, result); - - if (string.Equals(result.Status, "Failed", StringComparison.OrdinalIgnoreCase)) - { - return 1; - } - - if (VNetLinkService.IsRunning(result.Status)) - { - logger.LogInformation( - "{Operation} is still running. Check on it with: a365 network vnet status --operation-id {OperationId}", - operationLabel, - result.OperationId); - } - - return 0; - } - /// /// Creates the gsa enable or disable subcommand. The two differ only in the value they send /// and the words they use, so they share one builder. @@ -497,24 +250,4 @@ private static void LogGsaStatus(ILogger logger, GsaStatusResponse status) logger.LogWarning("Reason: {Reason}", status.Reason); } } - - private static void LogStatus(ILogger logger, VNetStatusResponse status) - { - logger.LogInformation("Status: {Status}", status.Status ?? "Unknown"); - - if (!string.IsNullOrWhiteSpace(status.PolicyArmId)) - { - logger.LogInformation("Policy: {PolicyArmId}", status.PolicyArmId); - } - - if (!string.IsNullOrWhiteSpace(status.OperationId)) - { - logger.LogInformation("Operation: {OperationId}", status.OperationId); - } - - if (!string.IsNullOrWhiteSpace(status.Reason)) - { - logger.LogWarning("Reason: {Reason}", status.Reason); - } - } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs deleted file mode 100644 index b2701a6d..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.A365.DevTools.Cli.Models; - -/// -/// Request body for linking a virtual network enterprise policy to the tenant's Agent 365 -/// Power Platform environment. -/// -public class VNetLinkRequest -{ - /// - /// The policy's properties.systemId, shaped - /// /regions/{region}/providers/Microsoft.PowerPlatform/enterprisePolicies/{guid}. - /// Resolved from the ARM policy id by the CLI, using the caller's own Azure session. - /// - [JsonPropertyName("policySystemId")] - public string? PolicySystemId { get; set; } - - /// - /// The policy's ARM resource id. Carried for display and audit only. - /// - [JsonPropertyName("policyArmId")] - public string? PolicyArmId { get; set; } - - /// - /// Whether an existing link to a different policy may be replaced. Mirrors the -Swap switch - /// on Enable-SubnetInjection. - /// - [JsonPropertyName("swap")] - public bool Swap { get; set; } -} - -/// -/// Status of the tenant's virtual network link, and the shape returned by link and unlink -/// once they settle. -/// -public class VNetStatusResponse -{ - /// - /// NotLinked, Running, Linked, Failed, or Unknown. - /// - [JsonPropertyName("status")] - public string? Status { get; set; } - - /// - /// ARM id of the linked policy as reported by the platform. Null when nothing is linked. - /// - [JsonPropertyName("policyArmId")] - public string? PolicyArmId { get; set; } - - /// - /// Handle for an operation that is still running, or has recently settled. - /// - [JsonPropertyName("operationId")] - public string? OperationId { get; set; } - - /// - /// Failure reason, when the platform has one to report. - /// - [JsonPropertyName("reason")] - public string? Reason { get; set; } -} - -/// -/// Error body returned by the platform's virtual network endpoints. -/// -public class VNetErrorResponse -{ - /// - /// Human-readable error message. - /// - [JsonPropertyName("error")] - public string? Error { get; set; } -} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 393bdb0a..52d29b94 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -186,11 +186,10 @@ await Task.WhenAll( var logRedactionService = serviceProvider.GetRequiredService(); rootCommand.AddCommand(LogsCommand.CreateCommand(logsLogger, logRedactionService)); var networkLogger = serviceProvider.GetRequiredService().CreateLogger("network"); - var vnetLinkService = serviceProvider.GetRequiredService(); var azureCliService = serviceProvider.GetRequiredService(); var gsaService = serviceProvider.GetRequiredService(); rootCommand.AddCommand(NetworkCommand.CreateCommand( - networkLogger, vnetLinkService, azureCliService, gsaService, confirmationProvider)); + networkLogger, azureCliService, gsaService, confirmationProvider)); // Build pipeline manually so we can skip UseTypoCorrections() ("Did you mean?" noise) // and UseParseErrorReporting() (full help dump on any parse error), replacing both @@ -385,11 +384,6 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini // Reuses the environment the tooling service already resolved (env var, then config file), // so the two never disagree about which Agent 365 deployment the CLI is talking to. - services.AddSingleton(provider => new VNetLinkService( - provider.GetRequiredService>(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService().Environment)); services.AddSingleton(provider => new GsaService( provider.GetRequiredService>(), provider.GetRequiredService(), diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs index 8a599e6f..150332dc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs @@ -8,7 +8,6 @@ using System.Net; using System.Net.Http.Headers; using System.Text.Json; -using System.Text.RegularExpressions; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -26,18 +25,6 @@ public class ArmApiService : IDisposable private const string ResourceGroupApiVersion = "2021-04-01"; private const string AppServiceApiVersion = "2022-03-01"; - // Stable first: the module's own ARM templates deploy enterprise policies at 2020-10-30. - private static readonly string[] EnterprisePolicyApiVersions = ["2020-10-30", "2020-10-30-preview"]; - - // ArmBaseUrl has no trailing slash and the ARM bearer token is set as a default request - // header, so a policy id that does not begin with "/subscriptions/" can retarget the whole - // request: "@evil.example/x" concatenates to "https://management.azure.com@evil.example/x", - // where "management.azure.com" is userinfo and the host is the attacker's. Pinning the shape - // is what keeps the token pointed at ARM. - private static readonly Regex EnterprisePolicyArmIdPattern = new( - @"^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/[^/?#]+/providers/Microsoft\.PowerPlatform/enterprisePolicies/[^/?#]+$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly IAuthenticationService _authService; @@ -256,112 +243,4 @@ private async Task EnsureArmHeadersAsync(string tenantId, CancellationToke } } - /// - /// Reads a Microsoft.PowerPlatform/enterprisePolicies resource and returns its - /// properties.systemId — the only identifier the Business App Platform accepts when - /// linking a policy to an environment. Shaped - /// /regions/{region}/providers/Microsoft.PowerPlatform/enterprisePolicies/{guid}, - /// which is not derivable from the ARM resource id. - /// - /// This read happens in the CLI, using the admin's own Azure session, so the Agent 365 - /// service never needs delegated ARM access. - /// - /// Returns null when the policy cannot be read or has no systemId; the message is logged. - /// - public virtual async Task GetEnterprisePolicySystemIdAsync( - string policyArmId, - string tenantId, - CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(policyArmId)) - throw new ArgumentException("Policy ARM id is required.", nameof(policyArmId)); - - if (!EnterprisePolicyArmIdPattern.IsMatch(policyArmId)) - { - _logger.LogError( - "'{PolicyArmId}' is not an enterprise policy ARM id. Expected " + - "/subscriptions/{{subscriptionId}}/resourceGroups/{{group}}/providers/" + - "Microsoft.PowerPlatform/enterprisePolicies/{{name}}, as returned by " + - "New-SubnetInjectionEnterprisePolicy.", - policyArmId); - return null; - } - - if (!await EnsureArmHeadersAsync(tenantId, ct)) - return null; - - // The stable and the preview version both ship on this RP and differ by tenant rollout, so - // a rejected api-version is a routine outcome rather than a failure worth surfacing. - foreach (var apiVersion in EnterprisePolicyApiVersions) - { - var url = $"{ArmBaseUrl}{policyArmId}?api-version={apiVersion}"; - _logger.LogDebug("ARM GET enterprise policy (api-version {ApiVersion})", apiVersion); - - try - { - using var response = await _retryHelper.ExecuteWithRetryAsync( - ct => _httpClient.GetAsync(url, ct), cancellationToken: ct); - - if (response.StatusCode == HttpStatusCode.BadRequest) - { - _logger.LogDebug("ARM rejected api-version {ApiVersion}; trying the next one", apiVersion); - continue; - } - - if (!response.IsSuccessStatusCode) - { - _logger.LogError( - "Could not read enterprise policy {PolicyArmId}. Azure returned {StatusCode}. " + - "Check that the policy exists and that you have read access to it.", - policyArmId, - response.StatusCode); - return null; - } - - var body = await response.Content.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(body); - - if (!doc.RootElement.TryGetProperty("properties", out var properties) || - !properties.TryGetProperty("systemId", out var systemId)) - { - _logger.LogError( - "Enterprise policy {PolicyArmId} has no systemId. The policy may still be provisioning.", - policyArmId); - return null; - } - - var value = systemId.GetString(); - if (string.IsNullOrWhiteSpace(value)) - { - _logger.LogError( - "Enterprise policy {PolicyArmId} has an empty systemId. The policy may still be provisioning.", - policyArmId); - return null; - } - - _logger.LogDebug("Resolved enterprise policy systemId"); - return value; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // RetryHelper rethrows cancellation deliberately. Swallowing it here would report - // Ctrl+C as "policy not found" and let the caller carry on as if the read had - // simply come back empty. - throw; - } - catch (Exception ex) - { - if (NetworkHelper.IsConnectionResetByProxy(ex)) - _logger.LogWarning(NetworkHelper.ConnectionResetWarning); - else - _logger.LogError(ex, "Failed to read enterprise policy {PolicyArmId}", policyArmId); - return null; - } - } - - _logger.LogError( - "Azure rejected every supported enterprise policy api-version reading {PolicyArmId}.", - policyArmId); - return null; - } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs deleted file mode 100644 index eb1854da..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Agents.A365.DevTools.Cli.Models; - -namespace Microsoft.Agents.A365.DevTools.Cli.Services; - -/// -/// Links an Azure virtual network enterprise policy to the tenant's Agent 365 Power Platform -/// environment through the Agent 365 platform, which resolves that environment itself. -/// -public interface IVNetLinkService -{ - /// - /// Links a policy. Resolves the policy's systemId from ARM using the caller's Azure session, - /// then asks the platform to perform the link. - /// - /// ARM resource id of the NetworkInjection enterprise policy. - /// Whether an existing link to a different policy may be replaced. - /// Tenant to authenticate against for the ARM read. - /// Cancellation token. - /// The resulting status, or null when the operation could not be started. - Task LinkAsync( - string policyArmId, - bool swap, - string tenantId, - CancellationToken cancellationToken = default); - - /// - /// Removes the current link. The platform supplies the policy identifier it stored at link time. - /// - /// Tenant to authenticate against. - /// Cancellation token. - /// The resulting status, or null when the operation could not be started. - Task UnlinkAsync( - string tenantId, - CancellationToken cancellationToken = default); - - /// - /// Reads the current link status, optionally resuming a specific operation handle. - /// - /// Tenant to authenticate against. - /// Handle returned by a link or unlink that was still running. - /// Cancellation token. - /// The current status, or null when it could not be read. - Task GetStatusAsync( - string tenantId, - string? operationId = null, - CancellationToken cancellationToken = default); - - /// - /// Polls status until the operation reaches a terminal state or the timeout elapses. - /// - /// Tenant to authenticate against. - /// Handle of the running operation. - /// How long to keep polling. - /// Cancellation token. - /// The last status read, which may still be Running if the timeout elapsed. - Task WaitForCompletionAsync( - string tenantId, - string operationId, - TimeSpan timeout, - CancellationToken cancellationToken = default); -} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs deleted file mode 100644 index de56537c..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Agents.A365.DevTools.Cli.Models; -using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; -using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; -using Microsoft.Extensions.Logging; -using System.Diagnostics; -using System.Net; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; - -namespace Microsoft.Agents.A365.DevTools.Cli.Services; - -/// -/// Calls the Agent 365 platform's /agents/vnet endpoints. -/// -/// The platform resolves the tenant's Power Platform environment itself, which is why this -/// replaces the final Enable-SubnetInjection step of the Microsoft.PowerPlatform.EnterprisePolicies -/// module: that cmdlet needs an environment id Agent 365 does not publish. -/// -/// The ARM read that turns a policy ARM id into the systemId the Business App Platform requires -/// happens here, in the CLI, under the admin's own Azure session. The platform therefore needs no -/// delegated ARM access of its own. -/// -public class VNetLinkService : IVNetLinkService -{ - private const string LinkPath = "/agents/vnet/link"; - private const string UnlinkPath = "/agents/vnet/unlink"; - private const string StatusPath = "/agents/vnet/status"; - - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); - - private readonly ILogger _logger; - private readonly IAuthenticationService _authService; - private readonly ArmApiService _armApiService; - private readonly string _environment; - private readonly HttpMessageHandler? _handler; - private readonly Func> _loginHintResolver; - - public VNetLinkService( - ILogger logger, - IAuthenticationService authService, - ArmApiService armApiService, - string environment = "prod", - HttpMessageHandler? handler = null, - Func>? loginHintResolver = null) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _authService = authService ?? throw new ArgumentNullException(nameof(authService)); - _armApiService = armApiService ?? throw new ArgumentNullException(nameof(armApiService)); - _environment = environment ?? "prod"; - _handler = handler; - _loginHintResolver = loginHintResolver ?? AzCliHelper.ResolveLoginHintAsync; - } - - /// - public async Task LinkAsync( - string policyArmId, - bool swap, - string tenantId, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(policyArmId)) - throw new ArgumentException("Policy ARM id is required.", nameof(policyArmId)); - - _logger.LogInformation("Reading enterprise policy from Azure..."); - var policySystemId = await _armApiService.GetEnterprisePolicySystemIdAsync(policyArmId, tenantId, cancellationToken); - if (string.IsNullOrWhiteSpace(policySystemId)) - { - _logger.LogError("Could not resolve the policy's systemId, so there is nothing to send to Agent 365."); - return null; - } - - var request = new VNetLinkRequest - { - PolicySystemId = policySystemId, - PolicyArmId = policyArmId, - Swap = swap, - }; - - _logger.LogInformation("Linking the policy to your Agent 365 environment..."); - return await SendAsync(HttpMethod.Post, LinkPath, request, "link virtual network", tenantId, cancellationToken); - } - - /// - public async Task UnlinkAsync( - string tenantId, - CancellationToken cancellationToken = default) - { - _logger.LogInformation("Removing the virtual network link from your Agent 365 environment..."); - return await SendAsync(HttpMethod.Post, UnlinkPath, payload: null, "unlink virtual network", tenantId, cancellationToken); - } - - /// - public async Task GetStatusAsync( - string tenantId, - string? operationId = null, - CancellationToken cancellationToken = default) - { - var path = string.IsNullOrWhiteSpace(operationId) - ? StatusPath - : $"{StatusPath}?operationId={Uri.EscapeDataString(operationId)}"; - - return await SendAsync(HttpMethod.Get, path, payload: null, "read virtual network status", tenantId, cancellationToken); - } - - /// - public async Task WaitForCompletionAsync( - string tenantId, - string operationId, - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(operationId)) - throw new ArgumentException("Operation id is required.", nameof(operationId)); - - // Wall clock, not summed sleeps: each status call costs real time, and a caller who asked - // for five minutes should not wait eight because the service was slow. - // - // The stopwatch alone only bounds the gap between completed polls. A poll that starts just - // inside the ceiling can still run to the HttpClient's own timeout, overshooting by minutes, - // so the ceiling is also armed on the token every request is made with. - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout); - - var stopwatch = Stopwatch.StartNew(); - VNetStatusResponse? last = null; - - while (true) - { - try - { - last = await GetStatusAsync(tenantId, operationId, timeoutCts.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - // The ceiling elapsed mid-request. That is a timeout, not a failure: report the - // last known state, exactly as the pre-sleep check below does. - _logger.LogInformation( - "Stopped waiting after {Elapsed:0}s. The operation is still running.", - stopwatch.Elapsed.TotalSeconds); - return last; - } - - if (last == null || !IsRunning(last.Status)) - return last; - - if (stopwatch.Elapsed + PollInterval >= timeout) - return last; - - _logger.LogInformation("Still running... ({Elapsed:0}s elapsed)", stopwatch.Elapsed.TotalSeconds); - // The pre-sleep check above guarantees this delay finishes inside the ceiling, so it - // waits on the caller's token only -- the timeout can't fire here. - await Task.Delay(PollInterval, cancellationToken); - } - } - - /// - /// True when the reported status means the operation has not settled yet. - /// - /// The platform reports a queued operation as NotStarted, which is as unsettled as Running: - /// treating it as terminal would make --wait return before the work had begun. - /// - public static bool IsRunning(string? status) => - string.Equals(status, "Running", StringComparison.OrdinalIgnoreCase) - || string.Equals(status, "NotStarted", StringComparison.OrdinalIgnoreCase); - - private async Task SendAsync( - HttpMethod method, - string path, - object? payload, - string operationName, - string tenantId, - CancellationToken cancellationToken) - { - var correlationId = HttpClientFactory.GenerateCorrelationId(); - var baseUrl = BuildBaseUrl(); - var url = $"{baseUrl}{path}"; - - try - { - var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); - var loginHint = await _loginHintResolver(); - - // The tenant matters as much here as on the ARM read: without it MSAL falls back to - // the common authority with only a login hint, so on a machine with several cached - // accounts the platform call can land in a different tenant than the policy read. - var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, userId: loginHint, ct: cancellationToken); - if (string.IsNullOrWhiteSpace(authToken)) - { - _logger.LogError("Failed to acquire an Agent 365 access token."); - return null; - } - - using var httpClient = HttpClientFactory.CreateAuthenticatedClient( - authToken, correlationId: correlationId, handler: _handler); - - using var request = new HttpRequestMessage(method, url); - if (payload != null) - { - var json = JsonSerializer.Serialize(payload); - request.Content = new StringContent(json, Encoding.UTF8); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - _logger.LogDebug("Request payload: {Payload}", json); - } - - _logger.LogDebug("{Method} {Url} (CorrelationId: {CorrelationId})", method, url, correlationId); - - using var response = await httpClient.SendAsync(request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("Response {StatusCode}: {Body}", response.StatusCode, body); - - if (!response.IsSuccessStatusCode) - { - LogFailure(response.StatusCode, body, operationName, correlationId); - return null; - } - - // 200 and 202 share a shape as far as the CLI is concerned: a status, and an - // operationId when there is more to wait for. - return string.IsNullOrWhiteSpace(body) - ? new VNetStatusResponse() - : JsonSerializer.Deserialize(body); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - if (NetworkHelper.IsConnectionResetByProxy(ex)) - _logger.LogWarning(NetworkHelper.ConnectionResetWarning); - else - _logger.LogError(ex, "Failed to {Operation}. Correlation ID: {CorrelationId}", operationName, correlationId); - return null; - } - } - - private void LogFailure(HttpStatusCode statusCode, string body, string operationName, string correlationId) - { - string? message = null; - try - { - message = JsonSerializer.Deserialize(body)?.Error; - } - catch (JsonException) - { - // The platform always sends a typed error body, so a non-JSON body means something - // upstream of it answered. The status code is then the only usable signal. - } - - _logger.LogError( - "Failed to {Operation}. Status: {StatusCode}. {Message}", - operationName, - statusCode, - message ?? "No error detail was returned."); - - if (statusCode == HttpStatusCode.Forbidden) - { - _logger.LogError( - "This command requires the Global Administrator or Power Platform Administrator role, " + - "and a client application consented for AgentTools.VNet.Manage.All."); - } - - _logger.LogError("Correlation ID: {CorrelationId}", correlationId); - } - - private string BuildBaseUrl() - { - var discoverUrl = ConfigConstants.GetDiscoverEndpointUrl(_environment); - var uri = new Uri(discoverUrl); - return $"{uri.Scheme}://{uri.Authority}"; - } -} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs index 57db251f..0be7a34d 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -20,20 +20,14 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// public class NetworkCommandTests { - private const string OperationId = "op-abc"; private const string TenantId = "tid"; - private const string PolicyArmId = - "/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p"; - private static Command CreateCommand( - IVNetLinkService? vnet = null, IAzureCliService? azure = null, IConfirmationProvider? confirmation = null, IGsaService? gsa = null) => NetworkCommand.CreateCommand( NullLogger.Instance, - vnet ?? Substitute.For(), azure ?? SignedInAzureCli(), gsa ?? Substitute.For(), confirmation ?? Confirming(true)); @@ -55,434 +49,18 @@ private static IConfirmationProvider Confirming(bool answer) // ──────────────────────────── Command tree shape ──────────────────────────── - [Fact] - public void CreateCommand_ExposesTheVnetSubcommandTree() - { - var command = CreateCommand(); - - command.Name.Should().Be("network"); - - var vnet = command.Subcommands.Single(c => c.Name == "vnet"); - vnet.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("link", "unlink", "status"); - } - [Fact] public void CreateCommand_ExposesTheGsaSubcommandTree() { var command = CreateCommand(); - command.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("vnet", "gsa"); + command.Name.Should().Be("network"); + command.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("gsa"); var gsa = command.Subcommands.Single(c => c.Name == "gsa"); gsa.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("enable", "disable", "status"); } - [Fact] - public void LinkSubcommand_RequiresPolicyArmIdAndOffersTheDocumentedOptions() - { - var link = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "link"); - - link.Options.Select(o => o.Name).Should() - .BeEquivalentTo("policy-arm-id", "swap", "tenant-id", "wait", "yes", "verbose"); - link.Options.Single(o => o.Name == "policy-arm-id").IsRequired.Should().BeTrue(); - link.Options.Single(o => o.Name == "swap").IsRequired.Should().BeFalse(); - } - - [Fact] - public void UnlinkSubcommand_TakesNoPolicyBecauseThePlatformStoredIt() - { - var unlink = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "unlink"); - - unlink.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "tenant-id", "yes", "verbose"); - } - - [Fact] - public void StatusSubcommand_AcceptsAnOperationHandle() - { - var status = CreateCommand().Subcommands[0].Subcommands.Single(c => c.Name == "status"); - - status.Options.Select(o => o.Name).Should().BeEquivalentTo("operation-id", "tenant-id", "verbose"); - } - - [Fact] - public void LinkSubcommand_ParsesItsOptions() - { - var command = CreateCommand(); - - var parsed = command.Parse("vnet link --policy-arm-id /p/1 --swap --tenant-id tid --wait"); - - parsed.Errors.Should().BeEmpty(); - } - - [Fact] - public void LinkSubcommand_WithoutPolicyArmId_FailsToParse() - { - var command = CreateCommand(); - - var parsed = command.Parse("vnet link"); - - parsed.Errors.Should().NotBeEmpty(because: "--policy-arm-id is required"); - } - - // ──────────────────────────── Handler invocation ──────────────────────────── - - [Fact] - public async Task LinkHandler_ResolvesTheTenantFromAzLoginAndCallsTheService() - { - var vnet = Substitute.For(); - vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); - - exitCode.Should().Be(0); - await vnet.Received(1).LinkAsync(PolicyArmId, false, TenantId, Arg.Any()); - } - - [Fact] - public async Task LinkHandler_PrefersAnExplicitTenantOverTheAzLoginTenant() - { - var vnet = Substitute.For(); - vnet.LinkAsync(PolicyArmId, false, "other-tenant", Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync( - $"vnet link --policy-arm-id {PolicyArmId} --tenant-id other-tenant"); - - exitCode.Should().Be(0); - await vnet.Received(1).LinkAsync(PolicyArmId, false, "other-tenant", Arg.Any()); - } - - [Fact] - public async Task LinkHandler_WithoutSwap_DoesNotPrompt() - { - var vnet = Substitute.For(); - vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); - var confirmation = Confirming(false); - var command = CreateCommand(vnet, confirmation: confirmation); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); - - exitCode.Should().Be(0, because: "a conflicting link is reported, not replaced, without --swap"); - await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); - } - - [Fact] - public async Task LinkHandler_WhenSwapDeclined_DoesNotCallTheService() - { - var vnet = Substitute.For(); - var command = CreateCommand(vnet, confirmation: Confirming(false)); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId} --swap"); - - exitCode.Should().Be(1); - await vnet.DidNotReceive().LinkAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task LinkHandler_WhenSwapAndYes_SkipsThePromptAndCallsTheService() - { - var vnet = Substitute.For(); - vnet.LinkAsync(PolicyArmId, true, TenantId, Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); - var confirmation = Confirming(false); - var command = CreateCommand(vnet, confirmation: confirmation); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId} --swap --yes"); - - exitCode.Should().Be(0); - await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); - await vnet.Received(1).LinkAsync(PolicyArmId, true, TenantId, Arg.Any()); - } - - [Fact] - public async Task LinkHandler_WhenTheServiceFails_ReturnsFailure() - { - var vnet = Substitute.For(); - vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) - .Returns(Task.FromResult(null)); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); - - exitCode.Should().Be(1); - } - - [Fact] - public async Task LinkHandler_WithWait_PollsUntilTheOperationSettles() - { - var vnet = Substitute.For(); - vnet.LinkAsync(PolicyArmId, false, TenantId, Arg.Any()) - .Returns(Task.FromResult( - new VNetStatusResponse { Status = "Running", OperationId = OperationId })); - vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId} --wait"); - - exitCode.Should().Be(0); - await vnet.Received(1).WaitForCompletionAsync( - TenantId, OperationId, Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task LinkHandler_WhenNoTenantCanBeResolved_FailsWithoutCallingTheService() - { - var vnet = Substitute.For(); - var command = CreateCommand(vnet, SignedInAzureCli(tenantId: null)); - - var exitCode = await command.InvokeAsync($"vnet link --policy-arm-id {PolicyArmId}"); - - exitCode.Should().Be(1); - await vnet.DidNotReceive().LinkAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task LinkHandler_WhenTenantIdSuppliedButBlank_FailsWithoutFallingBackToAzLogin() - { - var vnet = Substitute.For(); - var azure = SignedInAzureCli(); - var command = CreateCommand(vnet, azure); - - var exitCode = await command.InvokeAsync( - ["vnet", "link", "--policy-arm-id", PolicyArmId, "--tenant-id", " "]); - - exitCode.Should().Be(1, because: "a blank tenant is a mistake, not a request for the default"); - await azure.DidNotReceive().GetCurrentAccountAsync(); - await vnet.DidNotReceive().LinkAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task UnlinkHandler_PromptsThenCallsTheService() - { - var vnet = Substitute.For(); - vnet.UnlinkAsync(TenantId, Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "NotLinked" })); - var confirmation = Confirming(true); - var command = CreateCommand(vnet, confirmation: confirmation); - - var exitCode = await command.InvokeAsync("vnet unlink"); - - exitCode.Should().Be(0); - await confirmation.Received(1).ConfirmAsync(Arg.Any()); - await vnet.Received(1).UnlinkAsync(TenantId, Arg.Any()); - } - - [Fact] - public async Task UnlinkHandler_WhenDeclined_DoesNotCallTheService() - { - var vnet = Substitute.For(); - var command = CreateCommand(vnet, confirmation: Confirming(false)); - - var exitCode = await command.InvokeAsync("vnet unlink"); - - exitCode.Should().Be(1); - await vnet.DidNotReceive().UnlinkAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task UnlinkHandler_WithYes_SkipsThePrompt() - { - var vnet = Substitute.For(); - vnet.UnlinkAsync(TenantId, Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "NotLinked" })); - var confirmation = Confirming(false); - var command = CreateCommand(vnet, confirmation: confirmation); - - var exitCode = await command.InvokeAsync("vnet unlink --yes"); - - exitCode.Should().Be(0); - await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); - await vnet.Received(1).UnlinkAsync(TenantId, Arg.Any()); - } - - [Fact] - public async Task StatusHandler_ReadsTheCurrentLinkWithoutPrompting() - { - var vnet = Substitute.For(); - vnet.GetStatusAsync(TenantId, null, Arg.Any()) - .Returns(Task.FromResult( - new VNetStatusResponse { Status = "Linked", PolicyArmId = PolicyArmId })); - var confirmation = Confirming(false); - var command = CreateCommand(vnet, confirmation: confirmation); - - var exitCode = await command.InvokeAsync("vnet status"); - - exitCode.Should().Be(0); - await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); - await vnet.Received(1).GetStatusAsync(TenantId, null, Arg.Any()); - } - - [Fact] - public async Task StatusHandler_PassesTheOperationHandleThrough() - { - var vnet = Substitute.For(); - vnet.GetStatusAsync(TenantId, OperationId, Arg.Any()) - .Returns(Task.FromResult( - new VNetStatusResponse { Status = "Running", OperationId = OperationId })); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync($"vnet status --operation-id {OperationId}"); - - exitCode.Should().Be(0); - await vnet.Received(1).GetStatusAsync(TenantId, OperationId, Arg.Any()); - } - - [Fact] - public async Task StatusHandler_WhenFailed_ReturnsFailure() - { - var vnet = Substitute.For(); - vnet.GetStatusAsync(TenantId, null, Arg.Any()) - .Returns(Task.FromResult( - new VNetStatusResponse { Status = "Failed", Reason = "Region mismatch." })); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync("vnet status"); - - exitCode.Should().Be(1); - } - - [Fact] - public async Task StatusHandler_WhenStatusUnreadable_ReturnsFailure() - { - var vnet = Substitute.For(); - vnet.GetStatusAsync(TenantId, null, Arg.Any()) - .Returns(Task.FromResult(null)); - var command = CreateCommand(vnet); - - var exitCode = await command.InvokeAsync("vnet status"); - - exitCode.Should().Be(1); - } - - // ───────────────────────────────── ReportAsync ────────────────────────────── - - [Fact] - public async Task ReportAsync_WhenResultNull_ReturnsFailure() - { - var vnet = Substitute.For(); - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result: null, wait: true, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(1); - await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ReportAsync_WhenSettled_ReturnsSuccessWithoutWaiting() - { - var vnet = Substitute.For(); - var result = new VNetStatusResponse { Status = "Linked" }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(0); - await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ReportAsync_WhenFailed_ReturnsFailure() - { - var vnet = Substitute.For(); - var result = new VNetStatusResponse { Status = "Failed", Reason = "Region mismatch." }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: false, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(1); - } - - [Fact] - public async Task ReportAsync_WhenRunningAndNotWaiting_ReturnsSuccessAndLeavesTheHandle() - { - var vnet = Substitute.For(); - var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: false, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(0, because: "an accepted operation is not itself a failure"); - await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ReportAsync_WhenRunningAndWaiting_PollsThenReportsTheSettledStatus() - { - var vnet = Substitute.For(); - vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new VNetStatusResponse { Status = "Linked" })); - var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(0); - await vnet.Received(1).WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ReportAsync_WhenWaitSettlesAsFailed_ReturnsFailure() - { - var vnet = Substitute.For(); - vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) - .Returns(Task.FromResult( - new VNetStatusResponse { Status = "Failed", Reason = "Upstream rejected the link." })); - var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(1); - } - - [Fact] - public async Task ReportAsync_WhenWaitCannotReadStatus_ReturnsFailure() - { - var vnet = Substitute.For(); - vnet.WaitForCompletionAsync(TenantId, OperationId, Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - var result = new VNetStatusResponse { Status = "Running", OperationId = OperationId }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(1); - } - - [Fact] - public async Task ReportAsync_WhenRunningWithoutAHandle_DoesNotWait() - { - var vnet = Substitute.For(); - var result = new VNetStatusResponse { Status = "Running", OperationId = null }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Link", TenantId, CancellationToken.None); - - exitCode.Should().Be(0); - await vnet.DidNotReceive().WaitForCompletionAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ReportAsync_WhenUnlinkSettles_ReturnsSuccess() - { - var vnet = Substitute.For(); - var result = new VNetStatusResponse { Status = "NotLinked" }; - - var exitCode = await NetworkCommand.ReportAsync( - NullLogger.Instance, vnet, result, wait: true, "Unlink", TenantId, CancellationToken.None); - - exitCode.Should().Be(0); - } - // ─────────────────────────── GSA subcommand shape ─────────────────────────── [Theory] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs index 3fbead48..c003ff7d 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs @@ -29,12 +29,8 @@ public class ArmApiServiceTests private static IAuthenticationService FakeAuth() { var mock = Substitute.For(); - - // The 8th parameter is the CancellationToken. Without a matcher the setup is pinned to - // ct == default, so any call carrying a real token misses it and the service reports a - // failed token acquisition instead of doing the work under test. mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult("fake-arm-token")); return mock; } @@ -304,206 +300,6 @@ private static HttpResponseMessage BuildRoleAssignmentsResponse(string scope, st Content = new StringContent(body) }; } - - // ──────────────────────── GetEnterprisePolicySystemIdAsync ──────────────────────── - - private const string PolicyArmId = - "/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.PowerPlatform/enterprisePolicies/policy-1"; - - private const string PolicySystemId = - "/regions/unitedstates/providers/Microsoft.PowerPlatform/enterprisePolicies/1b2c8a4e-0000-0000-0000-000000000000"; - - private static HttpResponseMessage PolicyResponse(string body) => - new(HttpStatusCode.OK) { Content = new StringContent(body) }; - - [Theory] - // Userinfo trick: `management.azure.com` becomes the username and the real host is the attacker's. - [InlineData("@evil.example/x")] - [InlineData("evil.example/x")] - [InlineData("//evil.example/x")] - [InlineData("https://evil.example/x")] - [InlineData("/subscriptions/not-a-guid/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p")] - [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct")] - [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p?x=1")] - [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p#frag")] - [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p/../../x")] - public async Task GetEnterprisePolicySystemIdAsync_WhenArmIdIsNotAnEnterprisePolicyPath_RejectsWithoutCalling( - string policyArmId) - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(policyArmId, TenantId); - - result.Should().BeNull(); - handler.RequestCount.Should().Be( - 0, - because: "the ARM bearer token is a default header, so a redirected host would receive it"); - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_When200_ReturnsSystemId() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(PolicyResponse( - JsonSerializer.Serialize(new { properties = new { systemId = PolicySystemId } }))); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().Be(PolicySystemId, because: "the systemId is the only value BAP accepts for a link"); - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_RequestsTheArmPolicyResource() - { - HttpRequestMessage? captured = null; - using var handler = new CapturingHttpMessageHandler(r => captured = r); - handler.QueueResponse(PolicyResponse( - JsonSerializer.Serialize(new { properties = new { systemId = PolicySystemId } }))); - var svc = CreateService(handler); - - await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - captured.Should().NotBeNull(); - captured!.Method.Should().Be(HttpMethod.Get); - captured.RequestUri!.ToString().Should().Be( - $"https://management.azure.com{PolicyArmId}?api-version=2020-10-30"); - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_WhenStableApiVersionRejected_RetriesWithPreview() - { - var urls = new List(); - using var handler = new CapturingHttpMessageHandler(r => urls.Add(r.RequestUri!.ToString())); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("") }); - handler.QueueResponse(PolicyResponse( - JsonSerializer.Serialize(new { properties = new { systemId = PolicySystemId } }))); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().Be(PolicySystemId); - urls.Should().HaveCount(2); - urls[0].Should().EndWith("api-version=2020-10-30"); - urls[1].Should().EndWith("api-version=2020-10-30-preview"); - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_WhenEveryApiVersionRejected_ReturnsNull() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("") }); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("") }); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().BeNull(because: "there is no api-version left to try"); - } - - [Theory] - [InlineData(HttpStatusCode.Unauthorized)] - [InlineData(HttpStatusCode.Forbidden)] - [InlineData(HttpStatusCode.NotFound)] - [InlineData(HttpStatusCode.InternalServerError)] - public async Task GetEnterprisePolicySystemIdAsync_WhenNonSuccess_ReturnsNullWithoutRetrying(HttpStatusCode status) - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(status) { Content = new StringContent("") }); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().BeNull(because: "a rejected or missing policy is not an api-version problem"); - } - - [Theory] - [InlineData("{}")] - [InlineData("{\"properties\":{}}")] - [InlineData("{\"properties\":{\"systemId\":\"\"}}")] - [InlineData("{\"properties\":{\"systemId\":\" \"}}")] - public async Task GetEnterprisePolicySystemIdAsync_WhenSystemIdMissing_ReturnsNull(string body) - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(PolicyResponse(body)); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().BeNull(because: "a policy without a systemId is not yet usable for linking"); - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_WhenCallerCancels_PropagatesRatherThanReportingNoPolicy() - { - // RetryHelper rethrows cancellation on purpose. Folding it into the broad catch would - // report Ctrl+C as "could not read the policy" and let link carry on as if the policy - // simply did not exist. - using var handler = new SlowHttpMessageHandler(TimeSpan.FromSeconds(30)); - var svc = CreateService(handler); - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - - var act = async () => await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId, cts.Token); - - await act.Should().ThrowAsync(); - } - - /// - /// Holds each request open until the request's own token is cancelled, so a test can observe - /// what the service does with a cancellation raised mid-call. - /// - private sealed class SlowHttpMessageHandler(TimeSpan delay) : HttpMessageHandler - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - await Task.Delay(delay, cancellationToken); - return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; - } - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_WhenHttpThrows_ReturnsNull() - { - using var handler = new ThrowingHttpMessageHandler(); - var svc = CreateService(handler); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().BeNull(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task GetEnterprisePolicySystemIdAsync_WhenPolicyArmIdBlank_Throws(string? policyArmId) - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler); - - var act = async () => await svc.GetEnterprisePolicySystemIdAsync(policyArmId!, TenantId); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetEnterprisePolicySystemIdAsync_WhenTokenUnavailable_ReturnsNullWithoutCallingArm() - { - using var handler = new TestHttpMessageHandler(); - var auth = Substitute.For(); - auth.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(string.Empty)); - var svc = new ArmApiService(NullLogger.Instance, auth, handler, - retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); - - var result = await svc.GetEnterprisePolicySystemIdAsync(PolicyArmId, TenantId); - - result.Should().BeNull(); - handler.RequestCount.Should().Be(0, because: "without a token there is nothing worth sending"); - } } /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs deleted file mode 100644 index bc7783b8..00000000 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs +++ /dev/null @@ -1,561 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.Net; -using System.Text.Json; -using FluentAssertions; -using Microsoft.Agents.A365.DevTools.Cli.Services; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using Xunit; - -namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; - -/// -/// Unit tests for VNetLinkService. -/// Uses TestHttpMessageHandler / CapturingHttpMessageHandler (defined in GraphApiServiceTests.cs, -/// same assembly) to inject fake platform responses. -/// -public class VNetLinkServiceTests -{ - private const string TenantId = "tid"; - - private const string PolicyArmId = - "/subscriptions/sub-123/resourceGroups/rg-test/providers/Microsoft.PowerPlatform/enterprisePolicies/policy-1"; - - private const string PolicySystemId = - "/regions/unitedstates/providers/Microsoft.PowerPlatform/enterprisePolicies/1b2c8a4e-0000-0000-0000-000000000000"; - - private const string OperationId = "op-abc"; - - private static IAuthenticationService FakeAuth(string token = "fake-a365-token") - { - var mock = Substitute.For(); - - // The 8th parameter is the CancellationToken. Omitting a matcher for it pins the setup to - // ct == default, so any call carrying a real token -- a caller's, or the wait ceiling's -- - // silently misses and returns null, which the service reports as a failed token acquisition. - mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(token)); - return mock; - } - - private static ArmApiService FakeArm(string? systemId = PolicySystemId) - { - var arm = Substitute.For(); - arm.GetEnterprisePolicySystemIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(systemId)); - return arm; - } - - private static VNetLinkService CreateService( - HttpMessageHandler handler, - ArmApiService? arm = null, - IAuthenticationService? auth = null) => - new( - NullLogger.Instance, - auth ?? FakeAuth(), - arm ?? FakeArm(), - "prod", - handler, - NoLoginHint); - - /// - /// Stands in for the real resolver so the tests never shell out to `az account show`. - /// The production default caches in a static field shared with AzCliHelperTests. - /// - private static Task NoLoginHint() => Task.FromResult(null); - - private static HttpResponseMessage StatusResponse( - HttpStatusCode code, - string? status = null, - string? operationId = null, - string? policyArmId = null, - string? reason = null) => - new(code) - { - Content = new StringContent(JsonSerializer.Serialize(new - { - status, - policyArmId, - operationId, - reason, - })), - }; - - // ──────────────────────────────── IsRunning ──────────────────────────────── - - [Theory] - [InlineData("Running", true)] - [InlineData("running", true)] - [InlineData("RUNNING", true)] - [InlineData("NotStarted", true)] - [InlineData("notstarted", true)] - [InlineData("Linked", false)] - [InlineData("NotLinked", false)] - [InlineData("Failed", false)] - [InlineData("Unknown", false)] - [InlineData("", false)] - [InlineData(null, false)] - public void IsRunning_ClassifiesStatus(string? status, bool expected) - { - VNetLinkService.IsRunning(status).Should().Be(expected); - } - - // ──────────────────────────────── LinkAsync ──────────────────────────────── - - [Fact] - public async Task LinkAsync_SendsResolvedSystemIdNotTheArmId() - { - HttpRequestMessage? captured = null; - string? body = null; - using var handler = new CapturingHttpMessageHandler(r => - { - captured = r; - body = r.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); - }); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); - var svc = CreateService(handler); - - var result = await svc.LinkAsync(PolicyArmId, swap: true, TenantId); - - result.Should().NotBeNull(); - result!.Status.Should().Be("Linked"); - result.PolicyArmId.Should().BeNull(); - result.OperationId.Should().BeNull(); - result.Reason.Should().BeNull(); - - captured.Should().NotBeNull(); - captured!.Method.Should().Be(HttpMethod.Post); - captured.RequestUri!.AbsolutePath.Should().Be("/agents/vnet/link"); - - body.Should().NotBeNull(); - using var doc = JsonDocument.Parse(body!); - doc.RootElement.GetProperty("policySystemId").GetString().Should().Be(PolicySystemId); - doc.RootElement.GetProperty("policyArmId").GetString().Should().Be(PolicyArmId); - doc.RootElement.GetProperty("swap").GetBoolean().Should().BeTrue(); - } - - [Fact] - public async Task LinkAsync_WhenSwapNotRequested_SendsSwapFalse() - { - string? body = null; - using var handler = new CapturingHttpMessageHandler(r => - body = r.Content?.ReadAsStringAsync().GetAwaiter().GetResult()); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); - var svc = CreateService(handler); - - await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - using var doc = JsonDocument.Parse(body!); - doc.RootElement.GetProperty("swap").GetBoolean().Should().BeFalse(); - } - - [Fact] - public async Task LinkAsync_When202_ReturnsRunningWithOperationId() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.Accepted, "Running", OperationId)); - var svc = CreateService(handler); - - var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - result.Should().NotBeNull(); - result!.Status.Should().Be("Running"); - result.OperationId.Should().Be(OperationId); - result.PolicyArmId.Should().BeNull(); - result.Reason.Should().BeNull(); - } - - [Fact] - public async Task LinkAsync_WhenSystemIdCannotBeResolved_DoesNotCallThePlatform() - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler, FakeArm(systemId: null)); - - var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - result.Should().BeNull(); - handler.RequestCount.Should().Be(0, because: "there is no systemId to send"); - } - - [Theory] - [InlineData(HttpStatusCode.BadRequest)] - [InlineData(HttpStatusCode.Forbidden)] - [InlineData(HttpStatusCode.NotFound)] - [InlineData(HttpStatusCode.Conflict)] - [InlineData(HttpStatusCode.BadGateway)] - public async Task LinkAsync_WhenPlatformFails_ReturnsNull(HttpStatusCode status) - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(status) - { - Content = new StringContent(JsonSerializer.Serialize(new { error = "nope" })), - }); - var svc = CreateService(handler); - - var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - result.Should().BeNull(); - } - - [Fact] - public async Task LinkAsync_WhenErrorBodyIsNotJson_StillReturnsNull() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) - { - Content = new StringContent("gateway"), - }); - var svc = CreateService(handler); - - var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - result.Should().BeNull(because: "a non-JSON body means something upstream of the platform answered"); - } - - [Fact] - public async Task LinkAsync_WhenTokenUnavailable_ReturnsNullWithoutCallingThePlatform() - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler, auth: FakeAuth(string.Empty)); - - var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - result.Should().BeNull(); - handler.RequestCount.Should().Be(0); - } - - [Fact] - public async Task LinkAsync_WhenHttpThrows_ReturnsNull() - { - using var handler = new ThrowingHttpMessageHandler(); - var svc = CreateService(handler); - - var result = await svc.LinkAsync(PolicyArmId, swap: false, TenantId); - - result.Should().BeNull(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task LinkAsync_WhenPolicyArmIdBlank_Throws(string? policyArmId) - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler); - - var act = async () => await svc.LinkAsync(policyArmId!, swap: false, TenantId); - - await act.Should().ThrowAsync(); - } - - // ─────────────────────────────── UnlinkAsync ─────────────────────────────── - - [Fact] - public async Task UnlinkAsync_PostsToUnlinkWithNoBody() - { - HttpRequestMessage? captured = null; - using var handler = new CapturingHttpMessageHandler(r => captured = r); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotLinked")); - var svc = CreateService(handler); - - var result = await svc.UnlinkAsync(TenantId); - - result.Should().NotBeNull(); - result!.Status.Should().Be("NotLinked"); - result.PolicyArmId.Should().BeNull(); - result.OperationId.Should().BeNull(); - result.Reason.Should().BeNull(); - - captured!.Method.Should().Be(HttpMethod.Post); - captured.RequestUri!.AbsolutePath.Should().Be("/agents/vnet/unlink"); - captured.Content.Should().BeNull(because: "the platform supplies the stored policy itself"); - } - - [Fact] - public async Task UnlinkAsync_WhenPlatformFails_ReturnsNull() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Conflict) - { - Content = new StringContent(JsonSerializer.Serialize(new { error = "no stored policy" })), - }); - var svc = CreateService(handler); - - var result = await svc.UnlinkAsync(TenantId); - - result.Should().BeNull(); - } - - // ────────────────────────────── GetStatusAsync ───────────────────────────── - - [Fact] - public async Task GetStatusAsync_WithoutOperationId_OmitsTheQueryString() - { - HttpRequestMessage? captured = null; - using var handler = new CapturingHttpMessageHandler(r => captured = r); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", policyArmId: PolicyArmId)); - var svc = CreateService(handler); - - var result = await svc.GetStatusAsync(TenantId); - - result.Should().NotBeNull(); - result!.Status.Should().Be("Linked"); - result.PolicyArmId.Should().Be(PolicyArmId); - result.OperationId.Should().BeNull(); - result.Reason.Should().BeNull(); - - captured!.Method.Should().Be(HttpMethod.Get); - captured.RequestUri!.AbsolutePath.Should().Be("/agents/vnet/status"); - captured.RequestUri.Query.Should().BeEmpty(); - } - - [Fact] - public async Task GetStatusAsync_WithOperationId_EscapesItIntoTheQueryString() - { - HttpRequestMessage? captured = null; - using var handler = new CapturingHttpMessageHandler(r => captured = r); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Running", "a b/c")); - var svc = CreateService(handler); - - await svc.GetStatusAsync(TenantId, "a b/c"); - - captured!.RequestUri!.Query.Should().Be("?operationId=a%20b%2Fc"); - } - - [Fact] - public async Task GetStatusAsync_WhenBodyEmpty_ReturnsEmptyStatus() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }); - var svc = CreateService(handler); - - var result = await svc.GetStatusAsync(TenantId); - - result.Should().NotBeNull(); - result!.Status.Should().BeNull(); - result.PolicyArmId.Should().BeNull(); - result.OperationId.Should().BeNull(); - result.Reason.Should().BeNull(); - } - - [Fact] - public async Task GetStatusAsync_SurfacesTheFailureReason() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Failed", OperationId, reason: "Region mismatch.")); - var svc = CreateService(handler); - - var result = await svc.GetStatusAsync(TenantId, OperationId); - - result.Should().NotBeNull(); - result!.Status.Should().Be("Failed"); - result.Reason.Should().Be("Region mismatch."); - result.OperationId.Should().Be(OperationId); - result.PolicyArmId.Should().BeNull(); - } - - // ───────────────────────── WaitForCompletionAsync ────────────────────────── - - [Fact] - public async Task WaitForCompletionAsync_ReturnsAsSoonAsTheOperationSettles() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", OperationId)); - var svc = CreateService(handler); - - var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.FromMinutes(5)); - - result.Should().NotBeNull(); - result!.Status.Should().Be("Linked"); - handler.RequestCount.Should().Be(1, because: "a settled operation needs no second poll"); - } - - [Fact] - public async Task WaitForCompletionAsync_WhenStillRunningAndBudgetExhausted_ReturnsRunning() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Running", OperationId)); - var svc = CreateService(handler); - - // A zero budget cannot fit another poll interval, so the first read is also the last. - var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.Zero); - - result.Should().NotBeNull(); - result!.Status.Should().Be("Running"); - result.OperationId.Should().Be(OperationId); - handler.RequestCount.Should().Be(1); - } - - [Fact] - public async Task WaitForCompletionAsync_WhenStatusCannotBeRead_ReturnsNull() - { - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) - { - Content = new StringContent(JsonSerializer.Serialize(new { error = "upstream" })), - }); - var svc = CreateService(handler); - - var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.FromMinutes(5)); - - result.Should().BeNull(); - handler.RequestCount.Should().Be(1, because: "an unreadable status is terminal for the wait"); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task WaitForCompletionAsync_WhenOperationIdBlank_Throws(string? operationId) - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler); - - var act = async () => await svc.WaitForCompletionAsync(TenantId, operationId!, TimeSpan.FromMinutes(5)); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task WaitForCompletionAsync_WhenCeilingElapsesDuringAPoll_StopsWaitingOnTheInFlightCall() - { - // The pre-sleep stopwatch check only bounds the gap between completed polls. Without the - // ceiling armed on the request's own token, a poll that starts inside the budget runs to - // the HttpClient's timeout -- minutes past what the caller asked for. - using var handler = new SlowHttpMessageHandler( - TimeSpan.FromSeconds(30), - () => StatusResponse(HttpStatusCode.OK, "Running", OperationId)); - var svc = CreateService(handler); - - var stopwatch = Stopwatch.StartNew(); - var result = await svc.WaitForCompletionAsync(TenantId, OperationId, TimeSpan.FromMilliseconds(200)); - stopwatch.Stop(); - - result.Should().BeNull(because: "the ceiling elapsed before any status was read"); - stopwatch.Elapsed.Should().BeLessThan( - TimeSpan.FromSeconds(10), - because: "the wait must abandon the in-flight request rather than block on it"); - } - - [Fact] - public async Task WaitForCompletionAsync_WhenCallerCancels_PropagatesRatherThanReportingATimeout() - { - // The timeout and a Ctrl+C both surface as OperationCanceledException. Only the timeout is - // swallowed into "still running"; a caller cancel has to reach the caller. - using var handler = new SlowHttpMessageHandler( - TimeSpan.FromSeconds(30), - () => StatusResponse(HttpStatusCode.OK, "Running", OperationId)); - var svc = CreateService(handler); - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - - var act = async () => await svc.WaitForCompletionAsync( - TenantId, OperationId, TimeSpan.FromMinutes(5), cts.Token); - - await act.Should().ThrowAsync(); - } - - /// - /// Holds each request open for unless the request's own token is - /// cancelled first, so a test can tell "abandoned the call" from "waited for the response". - /// - private sealed class SlowHttpMessageHandler(TimeSpan delay, Func responseFactory) - : HttpMessageHandler - { - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - await Task.Delay(delay, cancellationToken); - return responseFactory(); - } - } - - // ────────────────────────── Token acquisition ────────────────────────────── - - [Fact] - public async Task LinkAsync_AcquiresTheAgent365TokenForTheRequestedTenant() - { - var auth = FakeAuth(); - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); - var svc = CreateService(handler, auth: auth); - - await svc.LinkAsync(PolicyArmId, swap: false, "contoso-tenant"); - - await auth.Received(1).GetAccessTokenAsync( - Arg.Any(), - "contoso-tenant", - Arg.Any(), - Arg.Any(), - Arg.Any?>(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task UnlinkAsync_AcquiresTheAgent365TokenForTheRequestedTenant() - { - var auth = FakeAuth(); - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotLinked")); - var svc = CreateService(handler, auth: auth); - - await svc.UnlinkAsync("contoso-tenant"); - - await auth.Received(1).GetAccessTokenAsync( - Arg.Any(), - "contoso-tenant", - Arg.Any(), - Arg.Any(), - Arg.Any?>(), - Arg.Any(), - Arg.Any()); - } - - [Fact] - public async Task GetStatusAsync_AcquiresTheAgent365TokenForTheRequestedTenant() - { - var auth = FakeAuth(); - using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked")); - var svc = CreateService(handler, auth: auth); - - await svc.GetStatusAsync("contoso-tenant"); - - await auth.Received(1).GetAccessTokenAsync( - Arg.Any(), - "contoso-tenant", - Arg.Any(), - Arg.Any(), - Arg.Any?>(), - Arg.Any(), - Arg.Any()); - } - - // ───────────────────────────── Constructor guards ────────────────────────── - - [Fact] - public void Constructor_WhenLoggerNull_Throws() - { - var act = () => new VNetLinkService(null!, FakeAuth(), FakeArm()); - act.Should().Throw(); - } - - [Fact] - public void Constructor_WhenAuthServiceNull_Throws() - { - var act = () => new VNetLinkService(NullLogger.Instance, null!, FakeArm()); - act.Should().Throw(); - } - - [Fact] - public void Constructor_WhenArmApiServiceNull_Throws() - { - var act = () => new VNetLinkService(NullLogger.Instance, FakeAuth(), null!); - act.Should().Throw(); - } -} From 94e71aad047c367c154f1b2f3d11430b39da78b2 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Wed, 23 Sep 2026 22:06:17 -0700 Subject: [PATCH 8/9] Resolve the az account once per run and pass it into GsaService The set handler read az account show to name the tenant in the confirmation prompt, and GsaService read it again to pick the tenant it authenticated against. az account show reflects mutable local state, so the two reads could disagree: the command could confirm tenant A and apply the tenant-wide setting to tenant B, and a transient CLI failure between them could fail a command whose tenant had already been resolved. GsaService is now told which account to act as. It no longer depends on IAzureCliService, and the account cache goes with it. That cache had its own bug: ??= stored any non-null account before its tenant was validated, and AzureAccountInfo defaults TenantId to string.Empty, so a blank-tenant account was cached permanently and never retried after a later az login. SendAsync also rethrew every OperationCanceledException. HttpClients own timeout surfaces as one with no token cancelled, so a bare enable, disable or status threw at the caller instead of returning the documented null. It now rethrows only when the supplied token is actually cancelled, which still covers both a Ctrl+C and the wait ceiling firing on its linked token. Also fixed the network-gsa.md prerequisite, which claimed nothing is read from Azure and repeated half a sentence after an earlier edit. 2065 passed, 0 failed, 12 skipped. --- docs/commands/network-gsa.md | 13 +- .../Commands/NetworkCommand.cs | 67 +++--- .../Program.cs | 1 - .../Services/GsaService.cs | 61 +++--- .../Services/IGsaService.cs | 18 +- .../Commands/NetworkCommandTests.cs | 92 +++++--- .../Services/GsaServiceTests.cs | 201 +++++++++--------- 7 files changed, 249 insertions(+), 204 deletions(-) diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md index ffdc91c5..f7374519 100644 --- a/docs/commands/network-gsa.md +++ b/docs/commands/network-gsa.md @@ -17,12 +17,13 @@ platform to apply the change against the environment it resolves for your tenant - An `az login` to the tenant you intend to configure. - Public cloud only. Sovereign clouds are not supported. -Nothing is read from Azure, but the `az login` still matters: it is what selects the tenant. The -what selects the tenant. The commands authenticate against the tenant and account of your current -`az account show`, so `az login --tenant ` is how you choose which tenant to configure when you -have more than one. Without an explicit tenant the Windows broker silently returns whichever -account Windows prefers, which would apply a tenant-wide setting to the wrong tenant. If the -account you are signed into cannot be matched, the command fails rather than falling back. +The `az login` is what selects the tenant. These commands read `az account show` once per run and +authenticate against the tenant and account it reports, so `az login --tenant ` is how you +choose which tenant to configure when you have more than one. Nothing else is read from Azure — the +setting itself lives in Power Platform, not in your subscription. Without an explicit tenant the +Windows broker silently returns whichever account Windows prefers, which would apply a tenant-wide +setting to the wrong tenant. If the account you are signed into cannot be matched, the command +fails rather than falling back. ## Subcommands diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs index 89b830db..836429b0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -41,47 +41,33 @@ public static Command CreateCommand( logger, gsaService, azureCliService, confirmationProvider, enabled: true)); gsaCommand.AddCommand(CreateGsaSetSubcommand( logger, gsaService, azureCliService, confirmationProvider, enabled: false)); - gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService)); + gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService, azureCliService)); networkCommand.AddCommand(gsaCommand); return networkCommand; } /// - /// Resolves the tenant to authenticate against, or logs why it could not and returns null. + /// Resolves the Azure account to act as, or logs why it could not and returns null. /// /// - /// An explicitly blank --tenant-id is treated as a mistake rather than as a request for - /// the default. Falling back silently would run a tenant-wide change against whichever tenant - /// az happens to be signed in to, which is not what someone who typed the option meant. + /// Resolved once per invocation and passed to every call that follows. az account show + /// reads mutable local state, so reading it again for authentication after prompting could + /// confirm one tenant and change another, and a transient CLI failure between two reads could + /// fail a command that had already succeeded at resolving the tenant. /// - internal static async Task ResolveTenantIdAsync( + internal static async Task ResolveAccountAsync( ILogger logger, - IAzureCliService azureCliService, - string? tenantIdOption) + IAzureCliService azureCliService) { - if (tenantIdOption is not null) - { - if (string.IsNullOrWhiteSpace(tenantIdOption)) - { - logger.LogError( - "--tenant-id was supplied but is empty. Pass a tenant id, or omit the option " + - "to use the tenant of your current az login."); - return null; - } - - return tenantIdOption; - } - var account = await azureCliService.GetCurrentAccountAsync(); - var tenantId = account?.TenantId; - if (string.IsNullOrWhiteSpace(tenantId)) + if (account is null || string.IsNullOrWhiteSpace(account.TenantId)) { - logger.LogError("Could not determine your Azure tenant. Run 'az login', or pass --tenant-id."); + logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again."); return null; } - return tenantId; + return account; } /// @@ -140,31 +126,34 @@ private static Command CreateGsaSetSubcommand( var yes = context.ParseResult.GetValueForOption(yesOption); var ct = context.GetCancellationToken(); - // GsaService authenticates against the same az login, so resolving here only names the - // tenant in the prompt — it does not pick a different one. - var tenantId = await ResolveTenantIdAsync(logger, azureCliService, tenantIdOption: null); - if (tenantId == null) + // Resolved once, then used for both the prompt and the call, so the tenant named in + // the prompt is provably the tenant changed. + var account = await ResolveAccountAsync(logger, azureCliService); + if (account == null) { context.ExitCode = 1; return; } if (!await ConfirmChangeAsync( - confirmationProvider, yes, $"Turn Global Secure Access {(enabled ? "on" : "off")}", tenantId)) + confirmationProvider, yes, $"Turn Global Secure Access {(enabled ? "on" : "off")}", account.TenantId)) { logger.LogInformation("Cancelled."); context.ExitCode = 1; return; } - var result = await gsaService.SetAsync(enabled, ct); - context.ExitCode = await ReportGsaAsync(logger, gsaService, result, wait, enabled, ct); + var result = await gsaService.SetAsync(account, enabled, ct); + context.ExitCode = await ReportGsaAsync(logger, gsaService, account, result, wait, enabled, ct); }); return command; } - private static Command CreateGsaStatusSubcommand(ILogger logger, IGsaService gsaService) + private static Command CreateGsaStatusSubcommand( + ILogger logger, + IGsaService gsaService, + IAzureCliService azureCliService) { var command = new Command( "status", @@ -177,7 +166,14 @@ private static Command CreateGsaStatusSubcommand(ILogger logger, IGsaService gsa { var ct = context.GetCancellationToken(); - var status = await gsaService.GetStatusAsync(ct); + var account = await ResolveAccountAsync(logger, azureCliService); + if (account == null) + { + context.ExitCode = 1; + return; + } + + var status = await gsaService.GetStatusAsync(account, ct); if (status == null) { context.ExitCode = 1; @@ -198,6 +194,7 @@ private static Command CreateGsaStatusSubcommand(ILogger logger, IGsaService gsa internal static async Task ReportGsaAsync( ILogger logger, IGsaService gsaService, + AzureAccountInfo account, GsaStatusResponse? result, bool wait, bool enabled, @@ -213,7 +210,7 @@ internal static async Task ReportGsaAsync( if (wait && result.Pending) { logger.LogInformation("The change is still being applied. Waiting for it to appear..."); - result = await gsaService.WaitForStatusAsync(expectedStatus, DefaultWaitTimeout, cancellationToken); + result = await gsaService.WaitForStatusAsync(account, expectedStatus, DefaultWaitTimeout, cancellationToken); if (result == null) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 52d29b94..73607b22 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -387,7 +387,6 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(provider => new GsaService( provider.GetRequiredService>(), provider.GetRequiredService(), - provider.GetRequiredService(), provider.GetRequiredService().Environment)); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs index a0abd85d..301f4991 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs @@ -31,36 +31,29 @@ public class GsaService : IGsaService private readonly ILogger _logger; private readonly IAuthenticationService _authService; - private readonly IAzureCliService _azureCliService; private readonly string _environment; private readonly HttpMessageHandler? _handler; - /// - /// The az account, resolved once and reused. re-enters - /// for every poll, and each resolution shells out to - /// az account show, which returns null on any CLI hiccup -- so without this a - /// transient failure part-way through a wait aborts the wait with "could not determine - /// your Azure tenant" even though the tenant was known all along. - /// - private AzureAccountInfo? _account; - public GsaService( ILogger logger, IAuthenticationService authService, - IAzureCliService azureCliService, string environment = "prod", HttpMessageHandler? handler = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); - _azureCliService = azureCliService ?? throw new ArgumentNullException(nameof(azureCliService)); _environment = environment ?? "prod"; _handler = handler; } /// - public async Task SetAsync(bool enabled, CancellationToken cancellationToken = default) + public async Task SetAsync( + AzureAccountInfo account, + bool enabled, + CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(account); + var path = enabled ? EnablePath : DisablePath; var operationName = enabled ? "enable Global Secure Access" : "disable Global Secure Access"; @@ -68,19 +61,29 @@ public GsaService( "{Action} Global Secure Access on your Agent 365 environment...", enabled ? "Enabling" : "Disabling"); - return await SendAsync(HttpMethod.Post, path, operationName, cancellationToken); + return await SendAsync(account, HttpMethod.Post, path, operationName, cancellationToken); } /// - public async Task GetStatusAsync(CancellationToken cancellationToken = default) => - await SendAsync(HttpMethod.Get, StatusPath, "read Global Secure Access status", cancellationToken); + public async Task GetStatusAsync( + AzureAccountInfo account, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(account); + + return await SendAsync( + account, HttpMethod.Get, StatusPath, "read Global Secure Access status", cancellationToken); + } /// public async Task WaitForStatusAsync( + AzureAccountInfo account, string expectedStatus, TimeSpan timeout, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(account); + if (string.IsNullOrWhiteSpace(expectedStatus)) throw new ArgumentException("Expected status is required.", nameof(expectedStatus)); @@ -100,7 +103,7 @@ public GsaService( { try { - last = await GetStatusAsync(timeoutCts.Token); + last = await GetStatusAsync(account, timeoutCts.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -127,6 +130,7 @@ public GsaService( } private async Task SendAsync( + AzureAccountInfo account, HttpMethod method, string path, string operationName, @@ -140,18 +144,11 @@ public GsaService( { var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); - // Authenticate against the tenant of the current az login, not whichever account the - // Windows broker happens to prefer. Without an explicit tenant the authority is - // "common", and WAM silently returns the Windows account even when a login hint names - // a different one — so a tenant-wide setting would be changed on the wrong tenant. - // Passing the tenant also arms the mismatch self-heal in AuthenticationService. - var account = _account ??= await _azureCliService.GetCurrentAccountAsync(); - if (account is null || string.IsNullOrWhiteSpace(account.TenantId)) - { - _logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again."); - return null; - } - + // Authenticate against the tenant of the az login the caller resolved, not whichever + // account the Windows broker happens to prefer. Without an explicit tenant the + // authority is "common", and WAM silently returns the Windows account even when a login + // hint names a different one — so a tenant-wide setting would be changed on the wrong + // tenant. Passing the tenant also arms the mismatch self-heal in AuthenticationService. var authToken = await _authService.GetAccessTokenAsync( audience, account.TenantId, userId: account.User.Name, ct: cancellationToken); if (string.IsNullOrWhiteSpace(authToken)) @@ -191,7 +188,11 @@ public GsaService( ? new GsaStatusResponse() : JsonSerializer.Deserialize(body); } - catch (OperationCanceledException) + // Cancellation is the caller's business, or the wait ceiling firing on a linked token. + // HttpClient's own timeout also surfaces as OperationCanceledException with no token + // cancelled, and that is an ordinary request failure — it belongs in the catch below so it + // is logged and reported, not thrown at whoever called enable, disable or status. + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs index 76ebcf50..bf00cdfa 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs @@ -9,31 +9,45 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// Turns Global Secure Access on and off for the tenant's Agent 365 Power Platform environment /// through the Agent 365 platform, which resolves that environment itself. /// +/// +/// Every method is told which Azure account to act as rather than reading the Azure CLI itself. +/// az account show reflects mutable local state, so resolving it once for the confirmation +/// prompt and again for authentication could confirm one tenant and change another. +/// public interface IGsaService { /// /// Sets Global Secure Access to the requested value. /// + /// The Azure account to authenticate as. Its tenant is the tenant changed. /// The value to apply. /// Cancellation token. /// The resulting status, or null when the change could not be requested. - Task SetAsync(bool enabled, CancellationToken cancellationToken = default); + Task SetAsync( + AzureAccountInfo account, + bool enabled, + CancellationToken cancellationToken = default); /// /// Reads the current Global Secure Access setting. /// + /// The Azure account to authenticate as. Its tenant is the tenant read. /// Cancellation token. /// The current status, or null when it could not be read. - Task GetStatusAsync(CancellationToken cancellationToken = default); + Task GetStatusAsync( + AzureAccountInfo account, + CancellationToken cancellationToken = default); /// /// Polls status until the environment reports the requested value or the timeout elapses. /// + /// The Azure account to authenticate as. Its tenant is the tenant polled. /// The status being waited for, Enabled or Disabled. /// How long to keep polling. /// Cancellation token. /// The last status read, which may still differ if the timeout elapsed. Task WaitForStatusAsync( + AzureAccountInfo account, string expectedStatus, TimeSpan timeout, CancellationToken cancellationToken = default); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs index 0be7a34d..5adf3ae5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -40,6 +40,9 @@ private static IAzureCliService SignedInAzureCli(string? tenantId = TenantId) return azure; } + private static AzureAccountInfo Account(string tenantId = TenantId) => + new() { TenantId = tenantId }; + private static IConfirmationProvider Confirming(bool answer) { var confirmation = Substitute.For(); @@ -83,7 +86,7 @@ public void GsaSetSubcommands_OfferWaitYesAndVerboseOnly(string name) public async Task GsaSetHandler_PromptsThenAppliesTheRequestedValue(string verb, bool enabled) { var gsa = Substitute.For(); - gsa.SetAsync(enabled, Arg.Any()) + gsa.SetAsync(Arg.Any(), enabled, Arg.Any()) .Returns(Task.FromResult( new GsaStatusResponse { Status = enabled ? "Enabled" : "Disabled" })); var confirmation = Confirming(true); @@ -93,7 +96,7 @@ public async Task GsaSetHandler_PromptsThenAppliesTheRequestedValue(string verb, exitCode.Should().Be(0); await confirmation.Received(1).ConfirmAsync(Arg.Any()); - await gsa.Received(1).SetAsync(enabled, Arg.Any()); + await gsa.Received(1).SetAsync(Arg.Any(), enabled, Arg.Any()); } [Theory] @@ -107,14 +110,14 @@ public async Task GsaSetHandler_WhenDeclined_DoesNotCallTheService(string verb) var exitCode = await command.InvokeAsync($"gsa {verb}"); exitCode.Should().Be(1); - await gsa.DidNotReceive().SetAsync(Arg.Any(), Arg.Any()); + await gsa.DidNotReceive().SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GsaSetHandler_WithYes_SkipsThePrompt() { var gsa = Substitute.For(); - gsa.SetAsync(true, Arg.Any()) + gsa.SetAsync(Arg.Any(), true, Arg.Any()) .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); var confirmation = Confirming(false); var command = CreateCommand(confirmation: confirmation, gsa: gsa); @@ -123,7 +126,7 @@ public async Task GsaSetHandler_WithYes_SkipsThePrompt() exitCode.Should().Be(0); await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); - await gsa.Received(1).SetAsync(true, Arg.Any()); + await gsa.Received(1).SetAsync(Arg.Any(), true, Arg.Any()); } [Fact] @@ -135,14 +138,49 @@ public async Task GsaSetHandler_WhenNoTenantCanBeResolved_FailsWithoutCallingThe var exitCode = await command.InvokeAsync("gsa enable"); exitCode.Should().Be(1); - await gsa.DidNotReceive().SetAsync(Arg.Any(), Arg.Any()); + await gsa.DidNotReceive().SetAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GsaSetHandler_ReadsTheAzAccountOnceAndActsOnTheTenantItConfirmed() + { + // az account show reads mutable local state, so a second read for authentication could + // confirm one tenant and change another. The account is resolved once and passed down. + const string tenantId = "44444444-4444-4444-4444-444444444444"; + var azure = SignedInAzureCli(tenantId); + var gsa = Substitute.For(); + gsa.SetAsync(Arg.Any(), true, Arg.Any()) + .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); + var confirmation = Confirming(true); + var command = CreateCommand(azure: azure, confirmation: confirmation, gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa enable"); + + exitCode.Should().Be(0); + await azure.Received(1).GetCurrentAccountAsync(); + await confirmation.Received(1).ConfirmAsync(Arg.Is(m => m.Contains(tenantId))); + await gsa.Received(1).SetAsync( + Arg.Is(a => a.TenantId == tenantId), true, Arg.Any()); + } + + [Fact] + public async Task GsaStatusHandler_WhenNoTenantCanBeResolved_FailsWithoutCallingTheService() + { + var gsa = Substitute.For(); + var command = CreateCommand(azure: SignedInAzureCli(tenantId: null), gsa: gsa); + + var exitCode = await command.InvokeAsync("gsa status"); + + exitCode.Should().Be(1); + await gsa.DidNotReceive().GetStatusAsync( + Arg.Any(), Arg.Any()); } [Fact] public async Task GsaSetHandler_WhenTheServiceFails_ReturnsFailure() { var gsa = Substitute.For(); - gsa.SetAsync(true, Arg.Any()).Returns(Task.FromResult(null)); + gsa.SetAsync(Arg.Any(), true, Arg.Any()).Returns(Task.FromResult(null)); var command = CreateCommand(gsa: gsa); var exitCode = await command.InvokeAsync("gsa enable --yes"); @@ -154,24 +192,24 @@ public async Task GsaSetHandler_WhenTheServiceFails_ReturnsFailure() public async Task GsaSetHandler_WithWait_PollsForTheRequestedValue() { var gsa = Substitute.For(); - gsa.SetAsync(true, Arg.Any()) + gsa.SetAsync(Arg.Any(), true, Arg.Any()) .Returns(Task.FromResult( new GsaStatusResponse { Status = "Disabled", Pending = true })); - gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + gsa.WaitForStatusAsync(Arg.Any(), "Enabled", Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); var command = CreateCommand(gsa: gsa); var exitCode = await command.InvokeAsync("gsa enable --yes --wait"); exitCode.Should().Be(0); - await gsa.Received(1).WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()); + await gsa.Received(1).WaitForStatusAsync(Arg.Any(), "Enabled", Arg.Any(), Arg.Any()); } [Fact] public async Task GsaStatusHandler_ReadsStatusWithoutPrompting() { var gsa = Substitute.For(); - gsa.GetStatusAsync(Arg.Any()) + gsa.GetStatusAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new GsaStatusResponse { Status = "Enabled" })); var confirmation = Confirming(false); var command = CreateCommand(confirmation: confirmation, gsa: gsa); @@ -180,14 +218,14 @@ public async Task GsaStatusHandler_ReadsStatusWithoutPrompting() exitCode.Should().Be(0); await confirmation.DidNotReceive().ConfirmAsync(Arg.Any()); - await gsa.Received(1).GetStatusAsync(Arg.Any()); + await gsa.Received(1).GetStatusAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task GsaStatusHandler_WhenStatusUnreadable_ReturnsFailure() { var gsa = Substitute.For(); - gsa.GetStatusAsync(Arg.Any()).Returns(Task.FromResult(null)); + gsa.GetStatusAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(null)); var command = CreateCommand(gsa: gsa); var exitCode = await command.InvokeAsync("gsa status"); @@ -222,11 +260,11 @@ public async Task ReportGsaAsync_WhenResultNull_ReturnsFailure() var gsa = Substitute.For(); var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result: null, wait: true, enabled: true, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result: null, wait: true, enabled: true, CancellationToken.None); exitCode.Should().Be(1); await gsa.DidNotReceive().WaitForStatusAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -236,11 +274,11 @@ public async Task ReportGsaAsync_WhenSettled_ReturnsSuccessWithoutWaiting() var result = new GsaStatusResponse { Status = "Enabled", Pending = false }; var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result, wait: true, enabled: true, CancellationToken.None); exitCode.Should().Be(0); await gsa.DidNotReceive().WaitForStatusAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -250,11 +288,11 @@ public async Task ReportGsaAsync_WhenPendingAndNotWaiting_ReturnsSuccess() var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result, wait: false, enabled: true, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result, wait: false, enabled: true, CancellationToken.None); exitCode.Should().Be(0, because: "an accepted change that has not surfaced yet is not a failure"); await gsa.DidNotReceive().WaitForStatusAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Theory] @@ -264,29 +302,29 @@ public async Task ReportGsaAsync_WhenPendingAndWaiting_PollsForTheRequestedStatu bool enabled, string expectedStatus) { var gsa = Substitute.For(); - gsa.WaitForStatusAsync(expectedStatus, Arg.Any(), Arg.Any()) + gsa.WaitForStatusAsync(Arg.Any(), expectedStatus, Arg.Any(), Arg.Any()) .Returns(Task.FromResult( new GsaStatusResponse { Status = expectedStatus, Pending = false })); var result = new GsaStatusResponse { Status = "NotConfigured", Pending = true }; var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result, wait: true, enabled, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result, wait: true, enabled, CancellationToken.None); exitCode.Should().Be(0); await gsa.Received(1).WaitForStatusAsync( - expectedStatus, Arg.Any(), Arg.Any()); + Arg.Any(), expectedStatus, Arg.Any(), Arg.Any()); } [Fact] public async Task ReportGsaAsync_WhenWaitCannotReadStatus_ReturnsFailure() { var gsa = Substitute.For(); - gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + gsa.WaitForStatusAsync(Arg.Any(), "Enabled", Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result, wait: true, enabled: true, CancellationToken.None); exitCode.Should().Be(1); } @@ -295,13 +333,13 @@ public async Task ReportGsaAsync_WhenWaitCannotReadStatus_ReturnsFailure() public async Task ReportGsaAsync_WhenStillPendingAfterWaiting_ReturnsSuccess() { var gsa = Substitute.For(); - gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + gsa.WaitForStatusAsync(Arg.Any(), "Enabled", Arg.Any(), Arg.Any()) .Returns(Task.FromResult( new GsaStatusResponse { Status = "Disabled", Pending = true })); var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result, wait: true, enabled: true, CancellationToken.None); exitCode.Should().Be(0, because: "the platform accepted the change; the environment is catching up"); } @@ -318,7 +356,7 @@ public async Task ReportGsaAsync_WithAReasonOnASettledResult_StillSucceeds() }; var exitCode = await NetworkCommand.ReportGsaAsync( - NullLogger.Instance, gsa, result, wait: false, enabled: false, CancellationToken.None); + NullLogger.Instance, gsa, Account(), result, wait: false, enabled: false, CancellationToken.None); exitCode.Should().Be(0); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs index 818701a5..5c57cc17 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -33,24 +33,19 @@ private static IAuthenticationService FakeAuth(string token = "fake-a365-token") return mock; } - private static IAzureCliService FakeAzureCli( + private static AzureAccountInfo Account( string tenantId = "11111111-1111-1111-1111-111111111111", - string upn = "admin@contoso.onmicrosoft.com") - { - var mock = Substitute.For(); - mock.GetCurrentAccountAsync().Returns(Task.FromResult(new AzureAccountInfo + string upn = "admin@contoso.onmicrosoft.com") => + new() { TenantId = tenantId, User = new AzureUser { Name = upn }, - })); - return mock; - } + }; private static GsaService CreateService( HttpMessageHandler handler, - IAuthenticationService? auth = null, - IAzureCliService? azureCli = null) => - new(NullLogger.Instance, auth ?? FakeAuth(), azureCli ?? FakeAzureCli(), "prod", handler); + IAuthenticationService? auth = null) => + new(NullLogger.Instance, auth ?? FakeAuth(), "prod", handler); private static HttpResponseMessage StatusResponse( HttpStatusCode code, @@ -79,7 +74,7 @@ public async Task SetAsync_PostsToTheRouteThatCarriesTheIntent(bool enabled, str handler.QueueResponse(StatusResponse(HttpStatusCode.OK, enabled ? "Enabled" : "Disabled")); var svc = CreateService(handler); - var result = await svc.SetAsync(enabled); + var result = await svc.SetAsync(Account(), enabled); result.Should().NotBeNull(); result!.Status.Should().Be(enabled ? "Enabled" : "Disabled"); @@ -103,7 +98,7 @@ public async Task SetAsync_SendsNoEnvironmentIdentifierBecauseThePlatformResolve handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); var svc = CreateService(handler); - await svc.SetAsync(enabled: true); + await svc.SetAsync(Account(), enabled: true); body.Should().BeEmpty(); uri!.Query.Should().BeEmpty(); @@ -116,7 +111,7 @@ public async Task SetAsync_WhenAccepted_SurfacesThePendingFlagWithTheOldStatus() handler.QueueResponse(StatusResponse(HttpStatusCode.Accepted, "Disabled", pending: true)); var svc = CreateService(handler); - var result = await svc.SetAsync(enabled: true); + var result = await svc.SetAsync(Account(), enabled: true); result.Should().NotBeNull(); result!.Status.Should().Be("Disabled"); @@ -137,7 +132,7 @@ public async Task SetAsync_WhenGovernedByPolicy_ReturnsNull() }); var svc = CreateService(handler); - var result = await svc.SetAsync(enabled: true); + var result = await svc.SetAsync(Account(), enabled: true); result.Should().BeNull(); handler.RequestCount.Should().Be(1, because: "a governed setting cannot be fixed by retrying"); @@ -156,7 +151,7 @@ public async Task SetAsync_WhenTheCallFails_ReturnsNull(HttpStatusCode code) }); var svc = CreateService(handler); - var result = await svc.SetAsync(enabled: false); + var result = await svc.SetAsync(Account(), enabled: false); result.Should().BeNull(); } @@ -171,7 +166,7 @@ public async Task SetAsync_WhenTheErrorBodyIsNotJson_StillReturnsNullWithoutThro }); var svc = CreateService(handler); - var result = await svc.SetAsync(enabled: true); + var result = await svc.SetAsync(Account(), enabled: true); result.Should().BeNull(); } @@ -182,7 +177,7 @@ public async Task SetAsync_WhenNoTokenIsAvailable_ReturnsNullWithoutCallingThePl using var handler = new TestHttpMessageHandler(); var svc = CreateService(handler, FakeAuth(token: string.Empty)); - var result = await svc.SetAsync(enabled: true); + var result = await svc.SetAsync(Account(), enabled: true); result.Should().BeNull(); handler.RequestCount.Should().Be(0); @@ -195,7 +190,7 @@ public async Task SetAsync_WhenTheTransportThrows_ReturnsNull() () => new HttpRequestException("connection reset")); var svc = CreateService(handler); - var result = await svc.SetAsync(enabled: true); + var result = await svc.SetAsync(Account(), enabled: true); result.Should().BeNull(); } @@ -215,9 +210,9 @@ public async Task SetAsync_AuthenticatesAgainstTheTenantAndUserOfTheCurrentAzLog var auth = FakeAuth(); using var handler = new TestHttpMessageHandler(); handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); - var svc = CreateService(handler, auth, FakeAzureCli(tenantId, upn)); + var svc = CreateService(handler, auth); - await svc.SetAsync(enabled: true); + await svc.SetAsync(Account(tenantId, upn), enabled: true); await auth.Received(1).GetAccessTokenAsync( Arg.Any(), @@ -238,9 +233,9 @@ public async Task GetStatusAsync_AuthenticatesAgainstTheTenantAndUserOfTheCurren var auth = FakeAuth(); using var handler = new TestHttpMessageHandler(); handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled")); - var svc = CreateService(handler, auth, FakeAzureCli(tenantId, upn)); + var svc = CreateService(handler, auth); - await svc.GetStatusAsync(); + await svc.GetStatusAsync(Account(tenantId, upn)); await auth.Received(1).GetAccessTokenAsync( Arg.Any(), @@ -253,42 +248,6 @@ await auth.Received(1).GetAccessTokenAsync( Arg.Any()); } - [Fact] - public async Task SetAsync_WhenAzLoginIsUnavailable_ReturnsNullWithoutCallingThePlatform() - { - var azureCli = Substitute.For(); - azureCli.GetCurrentAccountAsync().Returns(Task.FromResult(null)); - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler, azureCli: azureCli); - - var result = await svc.SetAsync(enabled: true); - - result.Should().BeNull(); - handler.RequestCount.Should().Be(0); - } - - [Fact] - public async Task SetAsync_WhenTheAzAccountCarriesNoTenant_ReturnsNullWithoutCallingThePlatform() - { - using var handler = new TestHttpMessageHandler(); - var svc = CreateService(handler, azureCli: FakeAzureCli(tenantId: string.Empty)); - - var result = await svc.SetAsync(enabled: true); - - result.Should().BeNull(); - handler.RequestCount.Should().Be(0); - } - - [Fact] - public void Constructor_WithoutAnAzureCliService_Throws() - { - using var handler = new TestHttpMessageHandler(); - - var act = () => new GsaService(NullLogger.Instance, FakeAuth(), null!, "prod", handler); - - act.Should().Throw().WithParameterName("azureCliService"); - } - // ──────────────────────────────── GetStatusAsync ──────────────────────────── [Fact] @@ -304,7 +263,7 @@ public async Task GetStatusAsync_GetsTheStatusRoute() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotConfigured")); var svc = CreateService(handler); - var result = await svc.GetStatusAsync(); + var result = await svc.GetStatusAsync(Account()); result.Should().NotBeNull(); result!.Status.Should().Be("NotConfigured"); @@ -325,7 +284,7 @@ public async Task GetStatusAsync_WhenTheBodyIsEmpty_ReturnsAnEmptyStatus() }); var svc = CreateService(handler); - var result = await svc.GetStatusAsync(); + var result = await svc.GetStatusAsync(Account()); result.Should().NotBeNull(); result!.Status.Should().BeNull(); @@ -341,7 +300,7 @@ public async Task GetStatusAsync_SurfacesTheReason() HttpStatusCode.OK, "NotConfigured", reason: "This tenant has no Agent 365 environment yet.")); var svc = CreateService(handler); - var result = await svc.GetStatusAsync(); + var result = await svc.GetStatusAsync(Account()); result.Should().NotBeNull(); result!.Status.Should().Be("NotConfigured"); @@ -357,7 +316,7 @@ public async Task WaitForStatusAsync_WithoutAnExpectedStatus_Throws() using var handler = new TestHttpMessageHandler(); var svc = CreateService(handler); - var act = () => svc.WaitForStatusAsync(" ", TimeSpan.FromMinutes(1)); + var act = () => svc.WaitForStatusAsync(Account(), " ", TimeSpan.FromMinutes(1)); await act.Should().ThrowAsync(); } @@ -369,7 +328,7 @@ public async Task WaitForStatusAsync_WhenTheFirstReadAlreadyMatches_StopsImmedia handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); var svc = CreateService(handler); - var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + var result = await svc.WaitForStatusAsync(Account(), "Enabled", TimeSpan.FromMinutes(1)); result.Should().NotBeNull(); result!.Status.Should().Be("Enabled"); @@ -385,7 +344,7 @@ public async Task WaitForStatusAsync_MatchesStatusCaseInsensitively() handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "enabled")); var svc = CreateService(handler); - var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + var result = await svc.WaitForStatusAsync(Account(), "Enabled", TimeSpan.FromMinutes(1)); result.Should().NotBeNull(); result!.Status.Should().Be("enabled"); @@ -404,7 +363,7 @@ public async Task WaitForStatusAsync_WhenAReadFails_GivesUpRatherThanSpinning() }); var svc = CreateService(handler); - var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + var result = await svc.WaitForStatusAsync(Account(), "Enabled", TimeSpan.FromMinutes(1)); result.Should().BeNull(); handler.RequestCount.Should().Be(1); @@ -418,7 +377,7 @@ public async Task WaitForStatusAsync_WhenTheBudgetCannotCoverAnotherPoll_Returns var svc = CreateService(handler); // Shorter than the poll interval, so the first non-matching read is also the last. - var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromSeconds(1)); + var result = await svc.WaitForStatusAsync(Account(), "Enabled", TimeSpan.FromSeconds(1)); result.Should().NotBeNull(); result!.Status.Should().Be("Disabled"); @@ -437,7 +396,7 @@ public async Task WaitForStatusAsync_WhenCeilingElapsesDuringAPoll_StopsWaitingO var svc = CreateService(handler); var stopwatch = Stopwatch.StartNew(); - var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMilliseconds(200)); + var result = await svc.WaitForStatusAsync(Account(), "Enabled", TimeSpan.FromMilliseconds(200)); stopwatch.Stop(); result.Should().BeNull(because: "the ceiling elapsed before any status was read"); @@ -455,56 +414,92 @@ public async Task WaitForStatusAsync_WhenCallerCancels_PropagatesRatherThanRepor var svc = CreateService(handler); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); - var act = async () => await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(5), cts.Token); + var act = async () => await svc.WaitForStatusAsync(Account(), "Enabled", TimeSpan.FromMinutes(5), cts.Token); await act.Should().ThrowAsync(); } - // ───────────────────── az account resolution ──────────────────────────────── + // ───────────────── The account is supplied, never read from the CLI ───────── + // + // az account show reflects mutable local state. Resolving it once for the confirmation prompt + // and again here would let the command confirm one tenant and change another, so the caller + // resolves it once and every method is told which account to act as. [Fact] - public async Task WaitForStatusAsync_ResolvesTheAzAccountOnceAcrossEveryPoll() + public async Task SetAsync_WithoutAnAccount_Throws() { - // Each resolution shells out to `az account show` and returns null on any CLI hiccup, so - // re-resolving per poll turns a transient failure mid-wait into "could not determine your - // Azure tenant" even though the tenant was known from the first call. using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled", pending: true)); - var azureCli = FakeAzureCli(); - var svc = CreateService(handler, azureCli: azureCli); + var svc = CreateService(handler); - await svc.GetStatusAsync(); - await svc.WaitForStatusAsync("Enabled", TimeSpan.FromSeconds(1)); + var act = async () => await svc.SetAsync(null!, enabled: true); - await azureCli.Received(1).GetCurrentAccountAsync(); + await act.Should().ThrowAsync().WithParameterName("account"); + handler.RequestCount.Should().Be(0); } [Fact] - public async Task GetStatusAsync_WhenTheAccountIsUnavailable_RetriesOnTheNextCall() + public async Task GetStatusAsync_WithoutAnAccount_Throws() { - // The cache must not pin a failure: an admin who runs `az login` after the first attempt - // should not have to restart the process. using var handler = new TestHttpMessageHandler(); - handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); - var azureCli = Substitute.For(); - azureCli.GetCurrentAccountAsync().Returns( - Task.FromResult(null), - Task.FromResult(new AzureAccountInfo - { - TenantId = "11111111-1111-1111-1111-111111111111", - User = new AzureUser { Name = "admin@contoso.onmicrosoft.com" }, - })); - var svc = CreateService(handler, azureCli: azureCli); - - var first = await svc.GetStatusAsync(); - var second = await svc.GetStatusAsync(); - - first.Should().BeNull(because: "no az account means no tenant to authenticate against"); - second.Should().NotBeNull(); - second!.Status.Should().Be("Enabled"); - second.Pending.Should().BeFalse(); - second.Reason.Should().BeNull(); - await azureCli.Received(2).GetCurrentAccountAsync(); + var svc = CreateService(handler); + + var act = async () => await svc.GetStatusAsync(null!); + + await act.Should().ThrowAsync().WithParameterName("account"); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task WaitForStatusAsync_WithoutAnAccount_Throws() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var act = async () => await svc.WaitForStatusAsync(null!, "Enabled", TimeSpan.FromMinutes(1)); + + await act.Should().ThrowAsync().WithParameterName("account"); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task GetStatusAsync_WhenTheTransportTimesOutWithNoCancellation_ReturnsNullRatherThanThrowing() + { + // HttpClient's own timeout surfaces as an OperationCanceledException with no token + // cancelled. That is an ordinary request failure, and callers of a bare status, enable or + // disable expect the documented null, not an exception thrown at them. + using var handler = new ThrowingHttpMessageHandler(new TaskCanceledException("The request timed out.")); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(Account()); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetStatusAsync_WhenTheCallerCancels_PropagatesRatherThanReturningNull() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + using var handler = new ThrowingHttpMessageHandler(new TaskCanceledException("Cancelled.")); + var svc = CreateService(handler); + + var act = async () => await svc.GetStatusAsync(Account(), cts.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// Fails every request with a supplied exception, so a test can pin how the service classifies + /// it without racing a real timeout. + /// + private sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + throw exception; + } } /// From a15fac100a261f326049eb9c38635976eb3cccf4 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Wed, 23 Sep 2026 22:39:29 -0700 Subject: [PATCH 9/9] Do not let a per-request client dispose a shared handler CreateAuthenticatedClient built the client with HttpClient's default handler ownership, so a caller that holds one handler and builds a client per request lost the handler to the first client's disposal. GsaService is exactly that shape, so the second poll of WaitForStatusAsync would have failed with ObjectDisposedException against any handler that honours Dispose. A supplied handler now stays owned by whoever supplied it. --- .../Services/Internal/HttpClientFactory.cs | 9 ++- .../Services/GsaServiceTests.cs | 59 +++++++++++++++++++ .../Internal/HttpClientFactoryTests.cs | 50 ++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/HttpClientFactory.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/HttpClientFactory.cs index 1b2bb95d..fff80898 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/HttpClientFactory.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/HttpClientFactory.cs @@ -20,6 +20,13 @@ public static class HttpClientFactory /// Optional correlation ID for request tracing. If null, empty, or whitespace, /// a new GUID will be generated automatically. /// + /// + /// Optional message handler. It stays owned by whoever supplied it: the returned client does + /// not dispose it, so one handler can back several clients. Callers that build a client per + /// request from a handler they hold as a field depend on this — the default + /// ownership would let the first client's disposal take the shared + /// handler down and fail every later request with . + /// /// A configured HttpClient instance with the correlation ID applied. public static HttpClient CreateAuthenticatedClient( string? authToken = null, @@ -28,7 +35,7 @@ public static HttpClient CreateAuthenticatedClient( HttpMessageHandler? handler = null) { var client = handler != null - ? new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) } + ? new HttpClient(handler, disposeHandler: false) { Timeout = TimeSpan.FromMinutes(2) } : new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; if (!string.IsNullOrWhiteSpace(authToken)) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs index 5c57cc17..c3fa808b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -488,6 +488,35 @@ public async Task GetStatusAsync_WhenTheCallerCancels_PropagatesRatherThanReturn await act.Should().ThrowAsync(); } + [Fact] + public async Task GetStatusAsync_CalledTwice_DoesNotDisposeTheInjectedHandlerOnTheFirstCall() + { + // A client is built per request, but the handler is a field and outlives all of them. + // HttpClient's default ownership would have the first client's disposal take the handler + // down with it, so every later request -- including every poll after the first in + // WaitForStatusAsync -- would fail with ObjectDisposedException. + using var handler = new DisposalAwareHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled")); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler); + + var first = await svc.GetStatusAsync(Account()); + var second = await svc.GetStatusAsync(Account()); + + first.Should().NotBeNull(); + first!.Status.Should().Be("Disabled"); + first.Pending.Should().BeFalse(); + first.Reason.Should().BeNull(); + + second.Should().NotBeNull(); + second!.Status.Should().Be("Enabled"); + second.Pending.Should().BeFalse(); + second.Reason.Should().BeNull(); + + handler.DisposeCount.Should().Be(0); + handler.RequestCount.Should().Be(2); + } + /// /// Fails every request with a supplied exception, so a test can pin how the service classifies /// it without racing a real timeout. @@ -518,4 +547,34 @@ protected override async Task SendAsync( }; } } + + /// + /// Mimics a real handler's reaction to being disposed: it counts disposals and refuses to + /// serve afterwards, so a test can prove the service never disposes a handler it does not own. + /// A handler that ignores Dispose would let the defect pass unnoticed. + /// + private sealed class DisposalAwareHttpMessageHandler : HttpMessageHandler + { + private readonly Queue _responses = new(); + + public int DisposeCount { get; private set; } + + public int RequestCount { get; private set; } + + public void QueueResponse(HttpResponseMessage response) => _responses.Enqueue(response); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(DisposeCount > 0, this); + RequestCount++; + return Task.FromResult(_responses.Dequeue()); + } + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + } + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Internal/HttpClientFactoryTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Internal/HttpClientFactoryTests.cs index f91a6fa8..f1e81b01 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Internal/HttpClientFactoryTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Internal/HttpClientFactoryTests.cs @@ -243,4 +243,54 @@ public void CreateAuthenticatedClient_WithGeneratedCorrelationId_BothHeadersMatc correlationId.Should().Be(clientRequestId, "Both headers should have the same auto-generated correlation ID"); } + + [Fact] + public void CreateAuthenticatedClient_WithASuppliedHandler_LeavesTheHandlerAliveAfterTheClientIsDisposed() + { + // A supplied handler belongs to the caller, who commonly holds one as a field and builds a + // client per request. HttpClient's default ownership would have the first client's disposal + // take that handler down, failing every later request with ObjectDisposedException. + using var handler = new CountingHttpMessageHandler(); + + using (HttpClientFactory.CreateAuthenticatedClient(handler: handler)) + { + } + + handler.DisposeCount.Should().Be(0); + } + + [Fact] + public void CreateAuthenticatedClient_WithASuppliedHandler_CanBackSeveralClients() + { + using var handler = new CountingHttpMessageHandler(); + + var first = HttpClientFactory.CreateAuthenticatedClient(handler: handler); + using var second = HttpClientFactory.CreateAuthenticatedClient(handler: handler); + + first.Should().NotBeSameAs(second); + + // Disposing one client must not take the shared handler, or the other client is already + // broken before it sends anything. + first.Dispose(); + + handler.DisposeCount.Should().Be(0); + } + + /// + /// Counts disposals so a test can pin who owns a supplied handler. + /// + private sealed class CountingHttpMessageHandler : HttpMessageHandler + { + public int DisposeCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + throw new NotSupportedException("This handler exists only to observe disposal."); + + protected override void Dispose(bool disposing) + { + DisposeCount++; + base.Dispose(disposing); + } + } } \ No newline at end of file