diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aa369b..528f22d3 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, 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 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..b4757275 --- /dev/null +++ b/docs/commands/network.md @@ -0,0 +1,111 @@ +# `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 new file mode 100644 index 00000000..73ca8516 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -0,0 +1,359 @@ +// 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; +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, + 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)); + + networkCommand.AddCommand(vnetCommand); + 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, + 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; + } + + 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..e0e08538 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, confirmationProvider)); // 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..93285ff9 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; @@ -25,6 +26,26 @@ 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. + // + // IgnoreCase because ARM ids are case-insensitive and are commonly seen as "resourcegroups" + // or "microsoft.powerplatform"; the shape stays pinned either way. Segments exclude + // whitespace as well as delimiters, which is what actually rejects a trailing newline -- + // \z alone does not, because [^/?#]+ would absorb the newline before the anchor is reached. + // The dot-segment lookaheads keep the path from normalizing into a different resource -- + // harmless while the host is fixed, but the validated string should be the string that gets + // requested. + private static readonly Regex EnterprisePolicyArmIdPattern = new( + @"^/subscriptions/[0-9a-f-]{36}/resourceGroups/(?!\.{1,2}(?:/|\z))[^/?#\s]+/providers/Microsoft\.PowerPlatform/enterprisePolicies/(?!\.{1,2}\z)[^/?#\s]+\z", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly IAuthenticationService _authService; @@ -243,4 +264,112 @@ 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 new file mode 100644 index 00000000..eb1854da --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs @@ -0,0 +1,64 @@ +// 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/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/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs new file mode 100644 index 00000000..e049bbac --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs @@ -0,0 +1,281 @@ +// 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); + } + // 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 link, unlink or status. + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + 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..e9e8fe37 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -0,0 +1,473 @@ +// 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, 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) => + NetworkCommand.CreateCommand( + NullLogger.Instance, + vnet ?? Substitute.For(), + azure ?? SignedInAzureCli(), + 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 ──────────────────────────── + + [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", "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); + } +} 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..e00a739e 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; } @@ -300,6 +304,234 @@ 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")] + // Dot segments normalize the request path into a different resource before it is sent. + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/../providers/Microsoft.PowerPlatform/enterprisePolicies/p")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/./providers/Microsoft.PowerPlatform/enterprisePolicies/p")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/..")] + // .NET's $ matches before a trailing newline, and [^/?#]+ would have absorbed the newline + // anyway, so segments exclude whitespace and the anchor is \z. + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p\n")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p ")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourceGroups/r g/providers/Microsoft.PowerPlatform/enterprisePolicies/p")] + 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"); + } + + [Theory] + // ARM ids are case-insensitive, and the portal, the CLI and ARM itself all emit different + // casings of the same id. Rejecting any of them would fail a perfectly valid --policy-arm-id. + [InlineData("/subscriptions/8D1E5B21-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.PowerPlatform/enterprisePolicies/p")] + [InlineData("/subscriptions/8d1e5b21-0000-0000-0000-000000000000/resourcegroups/rg/providers/microsoft.powerplatform/enterprisepolicies/p")] + [InlineData("/SUBSCRIPTIONS/8D1E5B21-0000-0000-0000-000000000000/RESOURCEGROUPS/RG/PROVIDERS/MICROSOFT.POWERPLATFORM/ENTERPRISEPOLICIES/P")] + public async Task GetEnterprisePolicySystemIdAsync_AcceptsAnyCasingOfTheArmId(string policyArmId) + { + 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); + handler.RequestCount.Should().Be(1); + } + + [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/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 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..b1bbaab5 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs @@ -0,0 +1,663 @@ +// 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(); + } + + [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 link, unlink or + // status expect the documented null, not an exception thrown at them. + using var handler = new CancelThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(TenantId); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetStatusAsync_WhenTheCallerCancels_PropagatesRatherThanReturningNull() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + using var handler = new CancelThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var act = async () => await svc.GetStatusAsync(TenantId, operationId: null, cts.Token); + + 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 + // WaitForCompletionAsync -- would fail with ObjectDisposedException. + using var handler = new DisposalAwareHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Running", OperationId)); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Linked", OperationId)); + var svc = CreateService(handler); + + var first = await svc.GetStatusAsync(TenantId); + var second = await svc.GetStatusAsync(TenantId); + + first.Should().NotBeNull(); + first!.Status.Should().Be("Running"); + first.OperationId.Should().Be(OperationId); + first.PolicyArmId.Should().BeNull(); + first.Reason.Should().BeNull(); + + second.Should().NotBeNull(); + second!.Status.Should().Be("Linked"); + second.OperationId.Should().Be(OperationId); + second.PolicyArmId.Should().BeNull(); + second.Reason.Should().BeNull(); + + handler.DisposeCount.Should().Be(0); + handler.RequestCount.Should().Be(2); + } + + /// + /// Fails every request the way HttpClient's own timeout does — an OperationCanceledException + /// with no token cancelled — so a test can pin how the service classifies it. + /// + private sealed class CancelThrowingHttpMessageHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new TaskCanceledException("The request timed out."); + } + } + + /// + /// 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(); + } + } + + /// + /// 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); + } + } + + // ────────────────────────── 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(); + } +}