From 72f3ab47c17a4d699f51896528f15dc1b0bd4bf1 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Tue, 15 Sep 2026 12:04:55 -0700 Subject: [PATCH 1/2] Add a365 network gsa enable|disable|status Global Secure Access is a per-environment Power Platform setting, and Agent 365 does not publish the id of the managed environment it provisions, so the admin surfaces that take an environment id cannot reach it. The platform resolves the environment and applies the change; these commands carry no environment identifier at all. Two things that are not obvious from the diff: Power Platform applies the change asynchronously but issues no operation id for it, so unlike vnet there is no handle to poll. The CLI converges by re-reading the setting, which is why status takes no --operation-id. NotConfigured is reported distinctly from Disabled. A tenant that has never set the value has not turned it off, and the distinction changes what an admin should do next. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/commands/README.md | 3 + docs/commands/network-gsa.md | 80 +++++ .../Commands/NetworkCommand.cs | 133 +++++++- .../Models/GsaModels.cs | 47 +++ .../Program.cs | 7 +- .../Services/GsaService.cs | 202 +++++++++++ .../Services/IGsaService.cs | 40 +++ .../Commands/NetworkCommandTests.cs | 164 ++++++++- .../Services/GsaServiceTests.cs | 320 ++++++++++++++++++ 10 files changed, 991 insertions(+), 6 deletions(-) create mode 100644 docs/commands/network-gsa.md create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 320e9957..921f3f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. ### Added +- `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. Global Secure Access is a per-environment Power Platform setting, and the Agent 365 managed environment's id is not published, so the platform resolves that environment and applies the change on your behalf. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md). - `a365 network vnet link|unlink|status` — links an Azure virtual network to Agent 365 through a Power Platform NetworkInjection enterprise policy. Replaces `Enable-SubnetInjection` from the `Microsoft.PowerPlatform.EnterprisePolicies` module, which cannot be used because it requires the id of the Agent 365 managed environment and that id is not published. The CLI reads the policy's `systemId` from Azure with your existing `az login` and the platform performs the link against the environment it resolves for your tenant. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network.md](docs/commands/network.md). - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. diff --git a/docs/commands/README.md b/docs/commands/README.md index 14f3260b..8ec8f708 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -31,6 +31,9 @@ There is reference documentation for each command. | [network vnet link](network.md#link) | Link a NetworkInjection enterprise policy to your Agent 365 environment. | | [network vnet unlink](network.md#unlink) | Remove the virtual network link from your Agent 365 environment. | | [network vnet status](network.md#status) | Show whether a virtual network policy is linked to your Agent 365 environment. | +| [network gsa enable](network-gsa.md#enable-and-disable) | Turn Global Secure Access on for your Agent 365 environment. | +| [network gsa disable](network-gsa.md#enable-and-disable) | Turn Global Secure Access off for your Agent 365 environment. | +| [network gsa status](network-gsa.md#status) | Show whether Global Secure Access is on for your Agent 365 environment. | | [publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/publish) | Update manifest.json ID values and publish the package. Configure federated identity and app role assignments. | | [query-entra](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra) | Query Microsoft Entra ID for agent information including scopes, permissions, and consent status. | | [query-entra blueprint-scopes](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/query-entra#query-entra-blueprint-scopes) | List configured scopes and consent status for the agent blueprint. | diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md new file mode 100644 index 00000000..7dafb26b --- /dev/null +++ b/docs/commands/network-gsa.md @@ -0,0 +1,80 @@ +# `a365 network gsa` + +Turns **Global Secure Access** on or off for the tenant's Agent 365 Power Platform environment, +without needing the id of that environment. + +## Why this command exists + +Global Secure Access is a per-environment Power Platform setting. Agent 365 provisions a managed +environment for the tenant and does not publish its id, so the setting cannot be reached through +the Power Platform admin surfaces that take an environment id. These subcommands ask the Agent 365 +platform to apply the change against the environment it resolves for your tenant. + +## Prerequisites + +- **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects + anyone else. +- Public cloud only. Sovereign clouds are not supported. + +No `az login` is needed — unlike `a365 network vnet`, nothing is read from Azure. + +## Subcommands + +| Command | Description | +| --- | --- | +| `a365 network gsa enable` | Turn Global Secure Access on. | +| `a365 network gsa disable` | Turn Global Secure Access off. | +| `a365 network gsa status` | Show whether Global Secure Access is on. | + +### `enable` and `disable` + +```bash +a365 network gsa enable [--wait] +a365 network gsa disable [--wait] +``` + +| Option | Description | +| --- | --- | +| `--wait` | Keep polling until the change appears on the environment, instead of returning while it is still being applied. | + +Requesting the value the environment already holds is a no-op and succeeds. + +### `status` + +```bash +a365 network gsa status +``` + +There is no operation handle to pass. Power Platform applies the change asynchronously but issues +no operation id for it, so the CLI reports progress by re-reading the setting rather than by +polling a handle. + +## Statuses and exit codes + +| Status | Meaning | +| --- | --- | +| `Enabled` | Global Secure Access is on. | +| `Disabled` | Global Secure Access is off. | +| `NotConfigured` | The tenant has never set the value. This is **not** the same as `Disabled`. | + +A change that has been accepted but has not yet surfaced is reported as still being applied, with +the status still showing the value it has not yet displaced. + +Exit code is `1` on any request error, and `0` otherwise — including a change that is still being +applied, which is a legitimate outcome when `--wait` is not passed. + +## Typical flow + +```bash +a365 network gsa enable --wait +a365 network gsa status +``` + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `403` from the platform | Caller is not a Global or Power Platform Administrator, or the CLI app lacks consent for the `AgentTools.Gsa.*` scopes. | +| `409`, reporting a governing policy | A Power Platform policy owns this setting. Change it through that policy; the environment-level value is ignored while the policy applies. | +| `404`, reporting no environment | The tenant has no Agent 365 environment yet. | +| Status stays `NotConfigured` after `disable` | Read it again — the change is applied asynchronously and `--wait` is the way to block on it. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs index 9f18d8ee..2bca25a4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs @@ -27,7 +27,8 @@ public static class NetworkCommand public static Command CreateCommand( ILogger logger, IVNetLinkService vnetLinkService, - IAzureCliService azureCliService) + IAzureCliService azureCliService, + IGsaService gsaService) { var networkCommand = new Command("network", "Configure tenant networking for Agent 365"); @@ -40,7 +41,17 @@ public static Command CreateCommand( vnetCommand.AddCommand(CreateUnlinkSubcommand(logger, vnetLinkService)); vnetCommand.AddCommand(CreateStatusSubcommand(logger, vnetLinkService)); + var gsaCommand = new Command( + "gsa", + "Turn Global Secure Access on or off for your Agent 365 environment. " + + "Requires the Global Administrator or Power Platform Administrator role."); + + gsaCommand.AddCommand(CreateGsaSetSubcommand(logger, gsaService, enabled: true)); + gsaCommand.AddCommand(CreateGsaSetSubcommand(logger, gsaService, enabled: false)); + gsaCommand.AddCommand(CreateGsaStatusSubcommand(logger, gsaService)); + networkCommand.AddCommand(vnetCommand); + networkCommand.AddCommand(gsaCommand); return networkCommand; } @@ -219,6 +230,126 @@ internal static async Task ReportAsync( return 0; } + /// + /// Creates the gsa enable or disable subcommand. The two differ only in the value they send + /// and the words they use, so they share one builder. + /// + private static Command CreateGsaSetSubcommand(ILogger logger, IGsaService gsaService, bool enabled) + { + var verb = enabled ? "enable" : "disable"; + var command = new Command( + verb, + $"Turn Global Secure Access {(enabled ? "on" : "off")} for your Agent 365 environment."); + + var waitOption = new Option( + "--wait", + "Keep polling until the change appears on the environment, instead of returning while " + + "it is still being applied."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + + command.AddOption(waitOption); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var wait = context.ParseResult.GetValueForOption(waitOption); + var ct = context.GetCancellationToken(); + + var result = await gsaService.SetAsync(enabled, ct); + context.ExitCode = await ReportGsaAsync(logger, gsaService, result, wait, enabled, ct); + }); + + return command; + } + + private static Command CreateGsaStatusSubcommand(ILogger logger, IGsaService gsaService) + { + var command = new Command( + "status", + "Show whether Global Secure Access is on for your Agent 365 environment."); + + var verboseOption = new Option(["--verbose", "-v"], "Enable verbose logging"); + command.AddOption(verboseOption); + + command.SetHandler(async (InvocationContext context) => + { + var ct = context.GetCancellationToken(); + + var status = await gsaService.GetStatusAsync(ct); + if (status == null) + { + context.ExitCode = 1; + return; + } + + LogGsaStatus(logger, status); + context.ExitCode = 0; + }); + + return command; + } + + /// + /// Renders the outcome of a Global Secure Access change, optionally waiting for it to appear + /// first, and maps it to a process exit code. + /// + internal static async Task ReportGsaAsync( + ILogger logger, + IGsaService gsaService, + GsaStatusResponse? result, + bool wait, + bool enabled, + CancellationToken cancellationToken) + { + if (result == null) + { + return 1; + } + + var expectedStatus = enabled ? "Enabled" : "Disabled"; + + if (wait && result.Pending) + { + logger.LogInformation("The change is still being applied. Waiting for it to appear..."); + result = await gsaService.WaitForStatusAsync(expectedStatus, DefaultWaitTimeout, cancellationToken); + + if (result == null) + { + return 1; + } + } + + LogGsaStatus(logger, result); + + // Still pending is not a failure. The platform accepted the change and the environment + // will catch up; reporting non-zero here would break scripts that chain on success. + if (result.Pending) + { + logger.LogInformation( + "Still being applied. Check on it with: a365 network gsa status"); + } + + return 0; + } + + private static void LogGsaStatus(ILogger logger, GsaStatusResponse status) + { + logger.LogInformation("Global Secure Access: {Status}", status.Status ?? "Unknown"); + + if (string.Equals(status.Status, "NotConfigured", StringComparison.OrdinalIgnoreCase)) + { + // Worth spelling out: a tenant that has never set this is not the same as one that + // turned it off, and the distinction changes what an admin should do next. + logger.LogInformation("This tenant has never set Global Secure Access, so no value is stored."); + } + + if (!string.IsNullOrWhiteSpace(status.Reason)) + { + logger.LogWarning("Reason: {Reason}", status.Reason); + } + } + private static void LogStatus(ILogger logger, VNetStatusResponse status) { logger.LogInformation("Status: {Status}", status.Status ?? "Unknown"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs new file mode 100644 index 00000000..f06cbf19 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Status of Global Secure Access on the tenant's Agent 365 Power Platform environment, and the +/// shape returned by enable and disable. +/// +public class GsaStatusResponse +{ + /// + /// Enabled, Disabled, or NotConfigured. + /// + /// NotConfigured is not the same as Disabled: it means the tenant has never set the value. + /// The platform keeps the two apart, so the CLI does too. + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// True when a change was accepted but has not yet appeared on the environment. The + /// accompanying is then the value it has not yet displaced. + /// + [JsonPropertyName("pending")] + public bool Pending { get; set; } + + /// + /// Explanation the platform has to offer, when there is one. + /// + [JsonPropertyName("reason")] + public string? Reason { get; set; } +} + +/// +/// Error body returned by the platform's Global Secure Access endpoints. +/// +public class GsaErrorResponse +{ + /// + /// Human-readable error message. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 566a313c..d5fecab0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -188,7 +188,8 @@ await Task.WhenAll( var networkLogger = serviceProvider.GetRequiredService().CreateLogger("network"); var vnetLinkService = serviceProvider.GetRequiredService(); var azureCliService = serviceProvider.GetRequiredService(); - rootCommand.AddCommand(NetworkCommand.CreateCommand(networkLogger, vnetLinkService, azureCliService)); + var gsaService = serviceProvider.GetRequiredService(); + rootCommand.AddCommand(NetworkCommand.CreateCommand(networkLogger, vnetLinkService, azureCliService, gsaService)); // Build pipeline manually so we can skip UseTypoCorrections() ("Did you mean?" noise) // and UseParseErrorReporting() (full help dump on any parse error), replacing both @@ -388,6 +389,10 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService().Environment)); + services.AddSingleton(provider => new GsaService( + provider.GetRequiredService>(), + provider.GetRequiredService(), + provider.GetRequiredService().Environment)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs new file mode 100644 index 00000000..0de886d3 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Calls the Agent 365 platform's /agents/gsa endpoints. +/// +/// The setting lives on the tenant's Power Platform environment, whose id Agent 365 does not +/// publish. The platform resolves that environment itself, so these calls carry no environment +/// identifier at all. +/// +public class GsaService : IGsaService +{ + private const string EnablePath = "/agents/gsa/enable"; + private const string DisablePath = "/agents/gsa/disable"; + private const string StatusPath = "/agents/gsa/status"; + + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); + + private readonly ILogger _logger; + private readonly IAuthenticationService _authService; + private readonly string _environment; + private readonly HttpMessageHandler? _handler; + + public GsaService( + ILogger logger, + IAuthenticationService authService, + string environment = "prod", + HttpMessageHandler? handler = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _environment = environment ?? "prod"; + _handler = handler; + } + + /// + public async Task SetAsync(bool enabled, CancellationToken cancellationToken = default) + { + var path = enabled ? EnablePath : DisablePath; + var operationName = enabled ? "enable Global Secure Access" : "disable Global Secure Access"; + + _logger.LogInformation( + "{Action} Global Secure Access on your Agent 365 environment...", + enabled ? "Enabling" : "Disabling"); + + return await SendAsync(HttpMethod.Post, path, operationName, cancellationToken); + } + + /// + public async Task GetStatusAsync(CancellationToken cancellationToken = default) => + await SendAsync(HttpMethod.Get, StatusPath, "read Global Secure Access status", cancellationToken); + + /// + public async Task WaitForStatusAsync( + string expectedStatus, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(expectedStatus)) + throw new ArgumentException("Expected status is required.", nameof(expectedStatus)); + + // Wall clock, not summed sleeps: each status call costs real time, and a caller who asked + // for five minutes should not wait eight because the service was slow. + var stopwatch = Stopwatch.StartNew(); + + while (true) + { + var last = await GetStatusAsync(cancellationToken); + + if (last == null || string.Equals(last.Status, expectedStatus, StringComparison.OrdinalIgnoreCase)) + return last; + + if (stopwatch.Elapsed + PollInterval >= timeout) + return last; + + _logger.LogInformation("Still applying... ({Elapsed:0}s elapsed)", stopwatch.Elapsed.TotalSeconds); + await Task.Delay(PollInterval, cancellationToken); + } + } + + private async Task SendAsync( + HttpMethod method, + string path, + string operationName, + CancellationToken cancellationToken) + { + var correlationId = HttpClientFactory.GenerateCorrelationId(); + var baseUrl = BuildBaseUrl(); + var url = $"{baseUrl}{path}"; + + try + { + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire an Agent 365 access token."); + return null; + } + + using var httpClient = HttpClientFactory.CreateAuthenticatedClient( + authToken, correlationId: correlationId, handler: _handler); + + using var request = new HttpRequestMessage(method, url); + + // The platform derives everything it needs from the token, so enable and disable are + // distinguished by route rather than by a body. + if (method == HttpMethod.Post) + { + request.Content = new StringContent(string.Empty, Encoding.UTF8); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + + _logger.LogDebug("{Method} {Url} (CorrelationId: {CorrelationId})", method, url, correlationId); + + using var response = await httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("Response {StatusCode}: {Body}", response.StatusCode, body); + + if (!response.IsSuccessStatusCode) + { + LogFailure(response.StatusCode, body, operationName, correlationId); + return null; + } + + // 200 and 202 share a shape as far as the CLI is concerned: a status, plus a pending + // flag when the change has not surfaced yet. + return string.IsNullOrWhiteSpace(body) + ? new GsaStatusResponse() + : JsonSerializer.Deserialize(body); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (NetworkHelper.IsConnectionResetByProxy(ex)) + _logger.LogWarning(NetworkHelper.ConnectionResetWarning); + else + _logger.LogError(ex, "Failed to {Operation}. Correlation ID: {CorrelationId}", operationName, correlationId); + return null; + } + } + + private void LogFailure(HttpStatusCode statusCode, string body, string operationName, string correlationId) + { + string? message = null; + try + { + message = JsonSerializer.Deserialize(body)?.Error; + } + catch (JsonException) + { + // The platform always sends a typed error body, so a non-JSON body means something + // upstream of it answered. The status code is then the only usable signal. + } + + _logger.LogError( + "Failed to {Operation}. Status: {StatusCode}. {Message}", + operationName, + statusCode, + message ?? "No error detail was returned."); + + if (statusCode == HttpStatusCode.Forbidden) + { + _logger.LogError( + "This command requires the Global Administrator or Power Platform Administrator role, " + + "and a client application consented for AgentTools.Gsa.Manage.All."); + } + + if (statusCode == HttpStatusCode.Conflict) + { + // Retrying cannot fix this one, so say why rather than letting it look transient. + _logger.LogError( + "A Power Platform policy governs this setting. Change it through that policy instead."); + } + + _logger.LogError("Correlation ID: {CorrelationId}", correlationId); + } + + private string BuildBaseUrl() + { + var discoverUrl = ConfigConstants.GetDiscoverEndpointUrl(_environment); + var uri = new Uri(discoverUrl); + return $"{uri.Scheme}://{uri.Authority}"; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs new file mode 100644 index 00000000..76ebcf50 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Turns Global Secure Access on and off for the tenant's Agent 365 Power Platform environment +/// through the Agent 365 platform, which resolves that environment itself. +/// +public interface IGsaService +{ + /// + /// Sets Global Secure Access to the requested value. + /// + /// The value to apply. + /// Cancellation token. + /// The resulting status, or null when the change could not be requested. + Task SetAsync(bool enabled, CancellationToken cancellationToken = default); + + /// + /// Reads the current Global Secure Access setting. + /// + /// Cancellation token. + /// The current status, or null when it could not be read. + Task GetStatusAsync(CancellationToken cancellationToken = default); + + /// + /// Polls status until the environment reports the requested value or the timeout elapses. + /// + /// The status being waited for, Enabled or Disabled. + /// How long to keep polling. + /// Cancellation token. + /// The last status read, which may still differ if the timeout elapsed. + Task WaitForStatusAsync( + string expectedStatus, + TimeSpan timeout, + CancellationToken cancellationToken = default); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs index 68834a02..322a0c05 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs @@ -22,11 +22,15 @@ public class NetworkCommandTests { private const string OperationId = "op-abc"; - private static Command CreateCommand(IVNetLinkService? vnet = null, IAzureCliService? azure = null) => + private static Command CreateCommand( + IVNetLinkService? vnet = null, + IAzureCliService? azure = null, + IGsaService? gsa = null) => NetworkCommand.CreateCommand( NullLogger.Instance, vnet ?? Substitute.For(), - azure ?? Substitute.For()); + azure ?? Substitute.For(), + gsa ?? Substitute.For()); // ──────────────────────────── Command tree shape ──────────────────────────── @@ -37,11 +41,21 @@ public void CreateCommand_ExposesTheVnetSubcommandTree() command.Name.Should().Be("network"); - var vnet = command.Subcommands.Should().ContainSingle().Subject; - vnet.Name.Should().Be("vnet"); + var vnet = command.Subcommands.Single(c => c.Name == "vnet"); vnet.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("link", "unlink", "status"); } + [Fact] + public void CreateCommand_ExposesTheGsaSubcommandTree() + { + var command = CreateCommand(); + + command.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("vnet", "gsa"); + + var gsa = command.Subcommands.Single(c => c.Name == "gsa"); + gsa.Subcommands.Select(c => c.Name).Should().BeEquivalentTo("enable", "disable", "status"); + } + [Fact] public void LinkSubcommand_RequiresPolicyArmIdAndOffersTheDocumentedOptions() { @@ -214,4 +228,146 @@ public async Task ReportAsync_WhenUnlinkSettles_ReturnsSuccess() exitCode.Should().Be(0); } + + // ─────────────────────────── GSA subcommand shape ─────────────────────────── + + [Theory] + [InlineData("enable")] + [InlineData("disable")] + public void GsaSetSubcommands_OfferWaitAndVerboseOnly(string name) + { + var gsa = CreateCommand().Subcommands.Single(c => c.Name == "gsa"); + + var subcommand = gsa.Subcommands.Single(c => c.Name == name); + + subcommand.Options.Select(o => o.Name).Should().BeEquivalentTo("wait", "verbose"); + } + + [Fact] + public void GsaStatusSubcommand_TakesNoOperationHandle() + { + var gsa = CreateCommand().Subcommands.Single(c => c.Name == "gsa"); + + var status = gsa.Subcommands.Single(c => c.Name == "status"); + + // GSA converges on re-read rather than issuing a handle, so there is nothing to look up. + status.Options.Select(o => o.Name).Should().BeEquivalentTo(new[] { "verbose" }); + } + + [Fact] + public void GsaEnableSubcommand_ParsesItsOptions() + { + var parsed = CreateCommand().Parse("gsa enable --wait"); + + parsed.Errors.Should().BeEmpty(); + } + + // ──────────────────────────────── ReportGsaAsync ──────────────────────────── + + [Fact] + public async Task ReportGsaAsync_WhenResultNull_ReturnsFailure() + { + var gsa = Substitute.For(); + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result: null, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(1); + await gsa.DidNotReceive().WaitForStatusAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportGsaAsync_WhenSettled_ReturnsSuccessWithoutWaiting() + { + var gsa = Substitute.For(); + var result = new GsaStatusResponse { Status = "Enabled", Pending = false }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(0); + await gsa.DidNotReceive().WaitForStatusAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportGsaAsync_WhenPendingAndNotWaiting_ReturnsSuccess() + { + var gsa = Substitute.For(); + var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: false, enabled: true, CancellationToken.None); + + exitCode.Should().Be(0, because: "an accepted change that has not surfaced yet is not a failure"); + await gsa.DidNotReceive().WaitForStatusAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData(true, "Enabled")] + [InlineData(false, "Disabled")] + public async Task ReportGsaAsync_WhenPendingAndWaiting_PollsForTheRequestedStatus( + bool enabled, string expectedStatus) + { + var gsa = Substitute.For(); + gsa.WaitForStatusAsync(expectedStatus, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult( + new GsaStatusResponse { Status = expectedStatus, Pending = false })); + var result = new GsaStatusResponse { Status = "NotConfigured", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled, CancellationToken.None); + + exitCode.Should().Be(0); + await gsa.Received(1).WaitForStatusAsync( + expectedStatus, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ReportGsaAsync_WhenWaitCannotReadStatus_ReturnsFailure() + { + var gsa = Substitute.For(); + gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(1); + } + + [Fact] + public async Task ReportGsaAsync_WhenStillPendingAfterWaiting_ReturnsSuccess() + { + var gsa = Substitute.For(); + gsa.WaitForStatusAsync("Enabled", Arg.Any(), Arg.Any()) + .Returns(Task.FromResult( + new GsaStatusResponse { Status = "Disabled", Pending = true })); + var result = new GsaStatusResponse { Status = "Disabled", Pending = true }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: true, enabled: true, CancellationToken.None); + + exitCode.Should().Be(0, because: "the platform accepted the change; the environment is catching up"); + } + + [Fact] + public async Task ReportGsaAsync_WithAReasonOnASettledResult_StillSucceeds() + { + var gsa = Substitute.For(); + var result = new GsaStatusResponse + { + Status = "NotConfigured", + Pending = false, + Reason = "This tenant has no Agent 365 environment yet.", + }; + + var exitCode = await NetworkCommand.ReportGsaAsync( + NullLogger.Instance, gsa, result, wait: false, enabled: false, CancellationToken.None); + + exitCode.Should().Be(0); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs new file mode 100644 index 00000000..12617a36 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for GsaService. +/// Uses TestHttpMessageHandler / CapturingHttpMessageHandler (defined in GraphApiServiceTests.cs, +/// same assembly) to inject fake platform responses. +/// +public class GsaServiceTests +{ + private static IAuthenticationService FakeAuth(string token = "fake-a365-token") + { + var mock = Substitute.For(); + mock.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(token)); + return mock; + } + + private static GsaService CreateService( + HttpMessageHandler handler, + IAuthenticationService? auth = null) => + new(NullLogger.Instance, auth ?? FakeAuth(), "prod", handler); + + private static HttpResponseMessage StatusResponse( + HttpStatusCode code, + string? status = null, + bool pending = false, + string? reason = null) => + new(code) + { + Content = new StringContent(JsonSerializer.Serialize(new { status, pending, reason })), + }; + + // ───────────────────────────────── SetAsync ───────────────────────────────── + + [Theory] + [InlineData(true, "/agents/gsa/enable")] + [InlineData(false, "/agents/gsa/disable")] + public async Task SetAsync_PostsToTheRouteThatCarriesTheIntent(bool enabled, string expectedPath) + { + HttpMethod? method = null; + Uri? uri = null; + using var handler = new CapturingHttpMessageHandler(r => + { + method = r.Method; + uri = r.RequestUri; + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, enabled ? "Enabled" : "Disabled")); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled); + + result.Should().NotBeNull(); + result!.Status.Should().Be(enabled ? "Enabled" : "Disabled"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + + method.Should().Be(HttpMethod.Post); + uri!.AbsolutePath.Should().Be(expectedPath); + } + + [Fact] + public async Task SetAsync_SendsNoEnvironmentIdentifierBecauseThePlatformResolvesIt() + { + string? body = null; + Uri? uri = null; + using var handler = new CapturingHttpMessageHandler(r => + { + uri = r.RequestUri; + body = r.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler); + + await svc.SetAsync(enabled: true); + + body.Should().BeEmpty(); + uri!.Query.Should().BeEmpty(); + } + + [Fact] + public async Task SetAsync_WhenAccepted_SurfacesThePendingFlagWithTheOldStatus() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.Accepted, "Disabled", pending: true)); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Disabled"); + result.Pending.Should().BeTrue(); + result.Reason.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_WhenGovernedByPolicy_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Conflict) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + error = "A Power Platform policy governs this setting.", + })), + }); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(1, because: "a governed setting cannot be fixed by retrying"); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadGateway)] + public async Task SetAsync_WhenTheCallFails_ReturnsNull(HttpStatusCode code) + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(code) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "nope" })), + }); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: false); + + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_WhenTheErrorBodyIsNotJson_StillReturnsNullWithoutThrowing() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("gateway"), + }); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_WhenNoTokenIsAvailable_ReturnsNullWithoutCallingThePlatform() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, FakeAuth(token: string.Empty)); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task SetAsync_WhenTheTransportThrows_ReturnsNull() + { + using var handler = new ExceptionThrowingHttpMessageHandler( + () => new HttpRequestException("connection reset")); + var svc = CreateService(handler); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + } + + // ──────────────────────────────── GetStatusAsync ──────────────────────────── + + [Fact] + public async Task GetStatusAsync_GetsTheStatusRoute() + { + HttpMethod? method = null; + Uri? uri = null; + using var handler = new CapturingHttpMessageHandler(r => + { + method = r.Method; + uri = r.RequestUri; + }); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "NotConfigured")); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be("NotConfigured"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + + method.Should().Be(HttpMethod.Get); + uri!.AbsolutePath.Should().Be("/agents/gsa/status"); + } + + [Fact] + public async Task GetStatusAsync_WhenTheBodyIsEmpty_ReturnsAnEmptyStatus() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty), + }); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().BeNull(); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + } + + [Fact] + public async Task GetStatusAsync_SurfacesTheReason() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse( + HttpStatusCode.OK, "NotConfigured", reason: "This tenant has no Agent 365 environment yet.")); + var svc = CreateService(handler); + + var result = await svc.GetStatusAsync(); + + result.Should().NotBeNull(); + result!.Status.Should().Be("NotConfigured"); + result.Pending.Should().BeFalse(); + result.Reason.Should().Be("This tenant has no Agent 365 environment yet."); + } + + // ─────────────────────────────── WaitForStatusAsync ───────────────────────── + + [Fact] + public async Task WaitForStatusAsync_WithoutAnExpectedStatus_Throws() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler); + + var act = () => svc.WaitForStatusAsync(" ", TimeSpan.FromMinutes(1)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task WaitForStatusAsync_WhenTheFirstReadAlreadyMatches_StopsImmediately() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler); + + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Enabled"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForStatusAsync_MatchesStatusCaseInsensitively() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "enabled")); + var svc = CreateService(handler); + + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("enabled"); + result.Pending.Should().BeFalse(); + result.Reason.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForStatusAsync_WhenAReadFails_GivesUpRatherThanSpinning() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent(JsonSerializer.Serialize(new { error = "upstream" })), + }); + var svc = CreateService(handler); + + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromMinutes(1)); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } + + [Fact] + public async Task WaitForStatusAsync_WhenTheBudgetCannotCoverAnotherPoll_ReturnsTheLastRead() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled", pending: true)); + var svc = CreateService(handler); + + // Shorter than the poll interval, so the first non-matching read is also the last. + var result = await svc.WaitForStatusAsync("Enabled", TimeSpan.FromSeconds(1)); + + result.Should().NotBeNull(); + result!.Status.Should().Be("Disabled"); + result.Pending.Should().BeTrue(); + result.Reason.Should().BeNull(); + handler.RequestCount.Should().Be(1); + } +} From 2649ed8d90f53527b404a6f34a7058fd44db3cb5 Mon Sep 17 00:00:00 2001 From: "Lala Sushant Srivastava (from Dev Box)" Date: Tue, 15 Sep 2026 17:23:44 -0700 Subject: [PATCH 2/2] Target the az-login tenant when authenticating GSA commands GsaService asked for a token with a login hint but no tenant, so the authority stayed `common`. The Windows broker ignores the hint in that case and returns whichever account Windows prefers; the resulting UPN mismatch is only logged at Debug, so a tenant-wide setting could be applied to the wrong tenant without any visible warning. Passing the tenant also arms the existing mismatch self-heal in AuthenticationService, which is inert while tenantId is null. Resolve both tenant and user from a single `az account show` via IAzureCliService - the same source `vnet link` already uses - rather than adding a --tenant-id option the user would have to keep in sync with their az context. No az login, or an account with no tenant, now fails with a clear message instead of silently guessing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/commands/network-gsa.md | 10 +- .../Program.cs | 1 + .../Services/GsaService.cs | 20 +++- .../Services/GsaServiceTests.cs | 108 +++++++++++++++++- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/docs/commands/network-gsa.md b/docs/commands/network-gsa.md index 7dafb26b..8151e6f0 100644 --- a/docs/commands/network-gsa.md +++ b/docs/commands/network-gsa.md @@ -14,9 +14,15 @@ platform to apply the change against the environment it resolves for your tenant - **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects anyone else. +- An `az login` to the tenant you intend to configure. - Public cloud only. Sovereign clouds are not supported. -No `az login` is needed — unlike `a365 network vnet`, nothing is read from Azure. +Nothing is read from Azure — unlike `a365 network vnet` — but the `az login` still matters: it is +what selects the tenant. The commands authenticate against the tenant and account of your current +`az account show`, so `az login --tenant ` is how you choose which tenant to configure when you +have more than one. Without an explicit tenant the Windows broker silently returns whichever +account Windows prefers, which would apply a tenant-wide setting to the wrong tenant. If the +account you are signed into cannot be matched, the command fails rather than falling back. ## Subcommands @@ -78,3 +84,5 @@ a365 network gsa status | `409`, reporting a governing policy | A Power Platform policy owns this setting. Change it through that policy; the environment-level value is ignored while the policy applies. | | `404`, reporting no environment | The tenant has no Agent 365 environment yet. | | Status stays `NotConfigured` after `disable` | Read it again — the change is applied asynchronously and `--wait` is the way to block on it. | +| `Could not determine your Azure tenant` | No usable `az login`. Run `az login --tenant ` for the tenant you want to configure. | +| Sign-in prompt names the wrong account | The tenant comes from `az account show`. Run `az account set` / `az login --tenant ` to point at the intended tenant, then retry. | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index d5fecab0..46990a0f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -392,6 +392,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(provider => new GsaService( provider.GetRequiredService>(), provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService().Environment)); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs index 0de886d3..1aee0e7c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs @@ -31,17 +31,20 @@ public class GsaService : IGsaService private readonly ILogger _logger; private readonly IAuthenticationService _authService; + private readonly IAzureCliService _azureCliService; private readonly string _environment; private readonly HttpMessageHandler? _handler; public GsaService( ILogger logger, IAuthenticationService authService, + IAzureCliService azureCliService, string environment = "prod", HttpMessageHandler? handler = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _azureCliService = azureCliService ?? throw new ArgumentNullException(nameof(azureCliService)); _environment = environment ?? "prod"; _handler = handler; } @@ -104,8 +107,21 @@ public GsaService( try { var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); - var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + + // Authenticate against the tenant of the current az login, not whichever account the + // Windows broker happens to prefer. Without an explicit tenant the authority is + // "common", and WAM silently returns the Windows account even when a login hint names + // a different one — so a tenant-wide setting would be changed on the wrong tenant. + // Passing the tenant also arms the mismatch self-heal in AuthenticationService. + var account = await _azureCliService.GetCurrentAccountAsync(); + if (account is null || string.IsNullOrWhiteSpace(account.TenantId)) + { + _logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again."); + return null; + } + + var authToken = await _authService.GetAccessTokenAsync( + audience, account.TenantId, userId: account.User.Name, ct: cancellationToken); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire an Agent 365 access token."); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs index 12617a36..45236c9f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs @@ -4,6 +4,7 @@ using System.Net; using System.Text.Json; using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -27,10 +28,24 @@ private static IAuthenticationService FakeAuth(string token = "fake-a365-token") return mock; } + private static IAzureCliService FakeAzureCli( + string tenantId = "11111111-1111-1111-1111-111111111111", + string upn = "admin@contoso.onmicrosoft.com") + { + var mock = Substitute.For(); + mock.GetCurrentAccountAsync().Returns(Task.FromResult(new AzureAccountInfo + { + TenantId = tenantId, + User = new AzureUser { Name = upn }, + })); + return mock; + } + private static GsaService CreateService( HttpMessageHandler handler, - IAuthenticationService? auth = null) => - new(NullLogger.Instance, auth ?? FakeAuth(), "prod", handler); + IAuthenticationService? auth = null, + IAzureCliService? azureCli = null) => + new(NullLogger.Instance, auth ?? FakeAuth(), azureCli ?? FakeAzureCli(), "prod", handler); private static HttpResponseMessage StatusResponse( HttpStatusCode code, @@ -180,6 +195,95 @@ public async Task SetAsync_WhenTheTransportThrows_ReturnsNull() result.Should().BeNull(); } + // ─────────────────────────── Tenant targeting ─────────────────────────── + // + // The tenant of the current az login is passed explicitly to token acquisition. Without it + // the authority is "common", and the Windows broker silently returns the Windows account even + // when a login hint names a different one — which would apply a tenant-wide setting to the + // wrong tenant. Passing the tenant also arms the mismatch self-heal in AuthenticationService. + + [Fact] + public async Task SetAsync_AuthenticatesAgainstTheTenantAndUserOfTheCurrentAzLogin() + { + const string tenantId = "22222222-2222-2222-2222-222222222222"; + const string upn = "admin@fabrikam.onmicrosoft.com"; + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Enabled")); + var svc = CreateService(handler, auth, FakeAzureCli(tenantId, upn)); + + await svc.SetAsync(enabled: true); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + tenantId, + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + upn, + Arg.Any()); + } + + [Fact] + public async Task GetStatusAsync_AuthenticatesAgainstTheTenantAndUserOfTheCurrentAzLogin() + { + const string tenantId = "33333333-3333-3333-3333-333333333333"; + const string upn = "reader@fabrikam.onmicrosoft.com"; + var auth = FakeAuth(); + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled")); + var svc = CreateService(handler, auth, FakeAzureCli(tenantId, upn)); + + await svc.GetStatusAsync(); + + await auth.Received(1).GetAccessTokenAsync( + Arg.Any(), + tenantId, + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + upn, + Arg.Any()); + } + + [Fact] + public async Task SetAsync_WhenAzLoginIsUnavailable_ReturnsNullWithoutCallingThePlatform() + { + var azureCli = Substitute.For(); + azureCli.GetCurrentAccountAsync().Returns(Task.FromResult(null)); + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, azureCli: azureCli); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public async Task SetAsync_WhenTheAzAccountCarriesNoTenant_ReturnsNullWithoutCallingThePlatform() + { + using var handler = new TestHttpMessageHandler(); + var svc = CreateService(handler, azureCli: FakeAzureCli(tenantId: string.Empty)); + + var result = await svc.SetAsync(enabled: true); + + result.Should().BeNull(); + handler.RequestCount.Should().Be(0); + } + + [Fact] + public void Constructor_WithoutAnAzureCliService_Throws() + { + using var handler = new TestHttpMessageHandler(); + + var act = () => new GsaService(NullLogger.Instance, FakeAuth(), null!, "prod", handler); + + act.Should().Throw().WithParameterName("azureCliService"); + } + // ──────────────────────────────── GetStatusAsync ──────────────────────────── [Fact]