-
Notifications
You must be signed in to change notification settings - Fork 9
perf: cache Azure CLI Graph token across publish command Graph API calls #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sellakumaran
merged 3 commits into
microsoft:main
from
pratapladhani:perf/publish-graph-token-caching
Feb 17, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
219 changes: 219 additions & 0 deletions
219
...Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenCacheTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.Net; | ||
| using System.Net.Http; | ||
| using FluentAssertions; | ||
| using Microsoft.Agents.A365.DevTools.Cli.Services; | ||
| using Microsoft.Extensions.Logging; | ||
| using NSubstitute; | ||
| using Xunit; | ||
|
|
||
| namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; | ||
|
|
||
| /// <summary> | ||
| /// Tests to validate that Azure CLI Graph tokens are cached across consecutive | ||
| /// Graph API calls, avoiding redundant 'az' subprocess spawns. | ||
| /// </summary> | ||
| public class GraphApiServiceTokenCacheTests | ||
| { | ||
| /// <summary> | ||
| /// Helper: create a GraphApiService with a mock executor that counts calls | ||
| /// and returns a predictable token. | ||
| /// </summary> | ||
| private static (GraphApiService service, TestHttpMessageHandler handler, CommandExecutor executor) CreateService(string token = "cached-token") | ||
| { | ||
| var handler = new TestHttpMessageHandler(); | ||
| var logger = Substitute.For<ILogger<GraphApiService>>(); | ||
| var executor = Substitute.For<CommandExecutor>(Substitute.For<ILogger<CommandExecutor>>()); | ||
|
|
||
| executor.ExecuteAsync( | ||
| Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), | ||
| Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()) | ||
| .Returns(callInfo => | ||
| { | ||
| var cmd = callInfo.ArgAt<string>(0); | ||
| var args = callInfo.ArgAt<string>(1); | ||
| if (cmd == "az" && args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase)) | ||
| return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); | ||
| if (cmd == "az" && args != null && args.Contains("get-access-token", StringComparison.OrdinalIgnoreCase)) | ||
| return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = token, StandardError = string.Empty }); | ||
| return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); | ||
| }); | ||
|
|
||
| var service = new GraphApiService(logger, executor, handler); | ||
| return (service, handler, executor); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task MultipleGraphGetAsync_SameTenant_AcquiresTokenOnlyOnce() | ||
| { | ||
| // Arrange | ||
| var (service, handler, executor) = CreateService(); | ||
|
|
||
| try | ||
| { | ||
| // Queue 3 successful GET responses | ||
| for (int i = 0; i < 3; i++) | ||
| { | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"value\":[]}") | ||
| }); | ||
| } | ||
|
|
||
| // Act - make 3 consecutive Graph GET calls to the same tenant | ||
| var r1 = await service.GraphGetAsync("tenant-1", "/v1.0/path1"); | ||
| var r2 = await service.GraphGetAsync("tenant-1", "/v1.0/path2"); | ||
| var r3 = await service.GraphGetAsync("tenant-1", "/v1.0/path3"); | ||
|
|
||
| // Assert - all calls should succeed | ||
| r1.Should().NotBeNull(); | ||
| r2.Should().NotBeNull(); | ||
| r3.Should().NotBeNull(); | ||
|
|
||
| // The token should be acquired only ONCE (1 account show + 1 get-access-token = 2 az calls) | ||
| await executor.Received(1).ExecuteAsync( | ||
| "az", | ||
| Arg.Is<string>(s => s.Contains("get-access-token")), | ||
| Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()); | ||
|
|
||
| await executor.Received(1).ExecuteAsync( | ||
| "az", | ||
| Arg.Is<string>(s => s.Contains("account show")), | ||
| Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()); | ||
| } | ||
| finally | ||
| { | ||
| handler.Dispose(); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GraphGetAsync_DifferentTenants_AcquiresTokenForEach() | ||
| { | ||
| // Arrange | ||
| var (service, handler, executor) = CreateService(); | ||
|
|
||
| try | ||
| { | ||
| // Queue 2 responses | ||
| for (int i = 0; i < 2; i++) | ||
| { | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"value\":[]}") | ||
| }); | ||
| } | ||
|
|
||
| // Act - make calls to different tenants | ||
| var r1 = await service.GraphGetAsync("tenant-1", "/v1.0/path1"); | ||
| var r2 = await service.GraphGetAsync("tenant-2", "/v1.0/path2"); | ||
|
|
||
| // Assert | ||
| r1.Should().NotBeNull(); | ||
| r2.Should().NotBeNull(); | ||
|
|
||
| // Token should be acquired twice (once per tenant) | ||
| await executor.Received(2).ExecuteAsync( | ||
| "az", | ||
| Arg.Is<string>(s => s.Contains("get-access-token")), | ||
| Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()); | ||
| } | ||
| finally | ||
| { | ||
| handler.Dispose(); | ||
| } | ||
sellakumaran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| [Fact] | ||
| public async Task MixedGraphOperations_SameTenant_AcquiresTokenOnlyOnce() | ||
| { | ||
| // Arrange | ||
| var (service, handler, executor) = CreateService(); | ||
|
|
||
| try | ||
| { | ||
| // Queue responses for GET, POST, GET sequence | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"value\":[]}") | ||
| }); | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"id\":\"123\"}") | ||
| }); | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"value\":[]}") | ||
| }); | ||
|
|
||
| // Act - interleave GET and POST calls | ||
| var r1 = await service.GraphGetAsync("tenant-1", "/v1.0/path1"); | ||
| var r2 = await service.GraphPostAsync("tenant-1", "/v1.0/path2", new { name = "test" }); | ||
| var r3 = await service.GraphGetAsync("tenant-1", "/v1.0/path3"); | ||
|
|
||
| // Assert | ||
| r1.Should().NotBeNull(); | ||
| r2.Should().NotBeNull(); | ||
| r3.Should().NotBeNull(); | ||
|
|
||
| // Only one token acquisition across all operations | ||
| await executor.Received(1).ExecuteAsync( | ||
| "az", | ||
| Arg.Is<string>(s => s.Contains("get-access-token")), | ||
| Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()); | ||
| } | ||
| finally | ||
| { | ||
| handler.Dispose(); | ||
| } | ||
sellakumaran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| [Fact] | ||
| public void AzCliTokenCacheDuration_IsFiveMinutes() | ||
| { | ||
| // The cache duration should be a reasonable window to avoid stale tokens | ||
| // while eliminating redundant subprocess spawns within a single command. | ||
| GraphApiService.AzCliTokenCacheDuration.Should().Be(TimeSpan.FromMinutes(5)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task GraphGetAsync_ExpiredCache_AcquiresNewToken() | ||
| { | ||
| // Arrange | ||
| var (service, handler, executor) = CreateService(); | ||
|
|
||
| try | ||
| { | ||
| // Queue 2 successful GET responses | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"value\":[]}") | ||
| }); | ||
| handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{\"value\":[]}") | ||
| }); | ||
|
|
||
| // Act - First call should acquire token and cache it | ||
| await service.GraphGetAsync("tenant-1", "/v1.0/path1"); | ||
|
|
||
| // Simulate cache expiry by setting expiry to past | ||
| service.CachedAzCliTokenExpiry = DateTimeOffset.UtcNow.AddMinutes(-1); | ||
|
|
||
| // Second call should acquire new token because cache expired | ||
| await service.GraphGetAsync("tenant-1", "/v1.0/path2"); | ||
|
|
||
| // Assert - Token should be acquired twice (once for each call since cache expired) | ||
| await executor.Received(2).ExecuteAsync( | ||
| "az", | ||
| Arg.Is<string>(s => s.Contains("get-access-token")), | ||
| Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()); | ||
| } | ||
| finally | ||
| { | ||
| handler.Dispose(); | ||
| } | ||
sellakumaran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.