Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

### New features

- **`whoami` and `can-i` access diagnostics.** `whoami` reports the current credential type and, for Microsoft Entra ID connections, the principal, tenant, application id, user principal name, display name, and token expiry decoded from the Cosmos DB access token. `can-i <read|query|write|manage>` probes data-plane access with safe, non-mutating requests and reports `allow`, `deny`, or `indeterminate`. Both commands are data-plane only (no control-plane dependency): account-key and emulator connections are reported from the master key, and RBAC role assignments are not enumerated. Both support `--format` (`table`, `json`, or `csv`). ([#163](https://github.com/Azure/CosmosDBShell/issues/163))
- **Deterministic machine output and exit codes.** Global `--output`/`--quiet`, structured JSON/CSV machine mode, and stable process exit codes (`0`–`6`) for automation and CI. ([#173](https://github.com/Azure/CosmosDBShell/pull/173), [#155](https://github.com/Azure/CosmosDBShell/issues/155), [#176](https://github.com/Azure/CosmosDBShell/issues/176), [#177](https://github.com/Azure/CosmosDBShell/issues/177))
- **`setup-cosmosdb-shell` GitHub Action** and [CI/CD guide](docs/ci.md) for installing the self-contained shell in pipelines without a .NET SDK on the runner. ([#173](https://github.com/Azure/CosmosDBShell/pull/173))

Expand Down Expand Up @@ -90,6 +91,8 @@ A focused cycle on top of 1.1.115-preview. New `ttl` and `conflict` commands man

- **Structured (JSON) tool results for MCP.** MCP tool results now carry the machine-readable JSON payload (`result`/`outputText`/`error` plus `currentLocation`) as first-class `structuredContent` in addition to the existing JSON text block, so agents can consume structured results directly. The two representations are kept byte-for-byte equivalent, and text-only clients are unaffected. ([#154](https://github.com/Azure/CosmosDBShell/issues/154))

- **Destructive MCP commands now prompt for confirmation instead of being blocked.** When an MCP client invokes `delete`, `rm`, `rmcon`, or `rmdb`, the server sends an elicitation prompt describing the exact command line and only runs it if the user approves; declining, cancelling, or a client that cannot confirm results in nothing being executed. This removes the need for any write opt-in flag. ([#158](https://github.com/Azure/CosmosDBShell/issues/158))

### Fixes

- MCP clients that reject unknown protocol versions (for example, Claude Code) can now connect: the server no longer advertises an unsupported protocol version. ([#150](https://github.com/Azure/CosmosDBShell/pull/150))
Expand Down
156 changes: 156 additions & 0 deletions CosmosDBShell.Tests/CommandTests/CanICommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.CommandTests;

using System.Text.Json;
using Azure.Data.Cosmos.Shell.Commands;
using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Parser;
using Azure.Data.Cosmos.Shell.States;
using Azure.Data.Cosmos.Shell.Util;
using Microsoft.Azure.Cosmos;

/// <summary>
/// Offline unit tests for <see cref="CanICommand"/>. These cover input
/// validation, the non-probeable 'manage' action, and the master-key branch.
/// The live data-plane probes (read/query/write) are exercised by the emulator
/// integration tests.
/// </summary>
public class CanICommandTests
{
[Fact]
public async Task CanI_Disconnected_ThrowsNotConnected()
{
using var shell = ShellInterpreter.CreateInstance();
shell.State = new DisconnectedState();
var command = new CanICommand { Action = "read" };

await Assert.ThrowsAsync<NotConnectedException>(
() => command.ExecuteAsync(shell, new CommandState(), "can-i read", CancellationToken.None));
}

[Fact]
public async Task CanI_InvalidAction_ThrowsCommandException()
{
using var shell = ShellInterpreter.CreateInstance();
shell.State = new ConnectedState(CreateTestClient());
var command = new CanICommand { Action = "delete" };

var exception = await Assert.ThrowsAsync<CommandException>(
() => command.ExecuteAsync(shell, new CommandState(), "can-i delete", CancellationToken.None));

Assert.Equal(
MessageService.GetArgsString("command-can-i-invalid-action", "action", "delete"),
exception.Message);
}

[Fact]
public async Task CanI_Manage_ReportsIndeterminate()
{
using var shell = ShellInterpreter.CreateInstance();
shell.StdOutRedirect = "out.txt";
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new CanICommand { Action = "manage" };

var state = await command.ExecuteAsync(shell, new CommandState(), "can-i manage", CancellationToken.None);

var json = Assert.IsType<ShellJson>(state.Result).Value;
Assert.Equal("manage", json.GetProperty("action").GetString());
Assert.Equal("indeterminate", json.GetProperty("decision").GetString());
Assert.Equal("none", json.GetProperty("method").GetString());
}

[Fact]
public async Task CanI_ReadWithoutContainer_ThrowsCommandException()
{
using var shell = ShellInterpreter.CreateInstance();
shell.State = new ConnectedState(CreateTestClient());
var command = new CanICommand { Action = "read" };

var exception = await Assert.ThrowsAsync<CommandException>(
() => command.ExecuteAsync(shell, new CommandState(), "can-i read", CancellationToken.None));

Assert.Equal(
MessageService.GetString("command-can-i-requires-container"),
exception.Message);
}

[Fact]
public async Task CanI_KeyAuth_ReportsAllow()
{
using var shell = ShellInterpreter.CreateInstance();
shell.StdOutRedirect = "out.txt";
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new CanICommand { Action = "read", Database = "MyDB", Container = "Products" };

var state = await command.ExecuteAsync(shell, new CommandState(), "can-i read --database=MyDB --container=Products", CancellationToken.None);

var json = Assert.IsType<ShellJson>(state.Result).Value;
Assert.Equal("allow", json.GetProperty("decision").GetString());
Assert.Equal("key", json.GetProperty("method").GetString());
Assert.Equal("MyDB", json.GetProperty("database").GetString());
Assert.Equal("Products", json.GetProperty("container").GetString());
}

[Fact]
public async Task CanI_WithoutCommandFormat_DefersToSessionDefault()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new CanICommand { Action = "manage" };

var state = await command.ExecuteAsync(shell, new CommandState(), "can-i manage", CancellationToken.None);

Assert.False(state.OutputFormatExplicitlySet);
}

[Fact]
public async Task CanI_CsvFormat_SetsCsvOutput()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new CanICommand { Action = "read", Database = "MyDB", Container = "Products", OutputFormat = "csv" };

var state = await command.ExecuteAsync(shell, new CommandState(), "can-i read --database=MyDB --container=Products --format=csv", CancellationToken.None);

Assert.Equal(OutputFormat.CSV, state.OutputFormat);
Assert.NotNull(state.RenderUser);

var csv = state.GenerateOutputText();
Assert.Contains("decision", csv, StringComparison.Ordinal);
Assert.Contains("allow", csv, StringComparison.Ordinal);
}

[Fact]
public async Task CanI_JsonFormat_SetsJsonOutput()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new CanICommand { Action = "manage", OutputFormat = "json" };

var state = await command.ExecuteAsync(shell, new CommandState(), "can-i manage --format=json", CancellationToken.None);

Assert.Equal(OutputFormat.JSon, state.OutputFormat);
Assert.NotNull(state.RenderUser);
Assert.Contains("\"decision\"", state.GenerateOutputText(), StringComparison.Ordinal);
}

[Fact]
public async Task CanI_InvalidFormat_Throws()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new CanICommand { Action = "manage", OutputFormat = "xml" };

await Assert.ThrowsAsync<ArgumentException>(
() => command.ExecuteAsync(shell, new CommandState(), "can-i manage --format=xml", CancellationToken.None));
}

private static CosmosClient CreateTestClient()
{
var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
return new CosmosClient(connectionString);
}
}
111 changes: 111 additions & 0 deletions CosmosDBShell.Tests/CommandTests/WhoamiCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.CommandTests;

using System.Text.Json;
using Azure.Data.Cosmos.Shell.Commands;
using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Parser;
using Azure.Data.Cosmos.Shell.States;
using Azure.Data.Cosmos.Shell.Util;
using Microsoft.Azure.Cosmos;

/// <summary>
/// Offline unit tests for <see cref="WhoamiCommand"/>. These cover the
/// not-connected branch and the key/emulator branch where no Entra identity is
/// available. The token-decoding path is exercised through <see cref="JwtClaims"/>
/// tests; live-identity introspection is covered by integration tests.
/// </summary>
public class WhoamiCommandTests
{
[Fact]
public async Task Whoami_Disconnected_ThrowsNotConnected()
{
using var shell = ShellInterpreter.CreateInstance();
shell.State = new DisconnectedState();
var command = new WhoamiCommand();

await Assert.ThrowsAsync<NotConnectedException>(
() => command.ExecuteAsync(shell, new CommandState(), "whoami", CancellationToken.None));
}

[Fact]
public async Task Whoami_KeyAuth_ReportsNoIdentity()
{
using var shell = ShellInterpreter.CreateInstance();
shell.StdOutRedirect = "out.txt";
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new WhoamiCommand();

var state = await command.ExecuteAsync(shell, new CommandState(), "whoami", CancellationToken.None);

var json = Assert.IsType<ShellJson>(state.Result).Value;
Assert.Equal("AccountKey", json.GetProperty("credentialType").GetString());
Assert.False(json.GetProperty("identityAvailable").GetBoolean());
Assert.Equal(
MessageService.GetString("command-whoami-key-auth-note"),
json.GetProperty("note").GetString());
}

[Fact]
public async Task Whoami_WithoutCommandFormat_DefersToSessionDefault()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new WhoamiCommand();

var state = await command.ExecuteAsync(shell, new CommandState(), "whoami", CancellationToken.None);

Assert.False(state.OutputFormatExplicitlySet);
}

[Fact]
public async Task Whoami_JsonFormat_SetsJsonOutput()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new WhoamiCommand { OutputFormat = "json" };

var state = await command.ExecuteAsync(shell, new CommandState(), "whoami --format=json", CancellationToken.None);

Assert.Equal(OutputFormat.JSon, state.OutputFormat);
Assert.NotNull(state.RenderUser);
Assert.Contains("\"credentialType\"", state.GenerateOutputText(), StringComparison.Ordinal);
}

[Fact]
public async Task Whoami_CsvFormat_SetsCsvOutput()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new WhoamiCommand { OutputFormat = "csv" };

var state = await command.ExecuteAsync(shell, new CommandState(), "whoami --format=csv", CancellationToken.None);

Assert.Equal(OutputFormat.CSV, state.OutputFormat);
Assert.NotNull(state.RenderUser);

var csv = state.GenerateOutputText();
Assert.Contains("credentialType", csv, StringComparison.Ordinal);
Assert.Contains("AccountKey", csv, StringComparison.Ordinal);
}

[Fact]
public async Task Whoami_InvalidFormat_Throws()
{
using var shell = ShellInterpreter.CreateInstance();
shell.Connect(CreateTestClient(), credentialTypeOverride: "AccountKey");
var command = new WhoamiCommand { OutputFormat = "xml" };

await Assert.ThrowsAsync<ArgumentException>(
() => command.ExecuteAsync(shell, new CommandState(), "whoami --format=xml", CancellationToken.None));
}

private static CosmosClient CreateTestClient()
{
var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
return new CosmosClient(connectionString);
}
}
83 changes: 83 additions & 0 deletions CosmosDBShell.Tests/UtilTest/JwtClaimsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.UtilTest;

using System.Text;
using System.Text.Json;
using Azure.Data.Cosmos.Shell.Util;

/// <summary>
/// Unit tests for <see cref="JwtClaims"/>, the JWT payload reader used by the
/// <c>whoami</c> command. The helper decodes claims for local introspection only
/// and never validates the token signature.
/// </summary>
public class JwtClaimsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-a-jwt")]
[InlineData("only.two")]
[InlineData("a.!!!.c")]
public void TryDecodePayload_MalformedToken_ReturnsNull(string? token)
{
Assert.Null(JwtClaims.TryDecodePayload(token));
}

[Fact]
public void TryDecodePayload_ValidToken_ReturnsClaims()
{
var token = BuildToken("{\"oid\":\"abc\",\"tid\":\"tenant\"}");

var claims = JwtClaims.TryDecodePayload(token);

Assert.NotNull(claims);
Assert.Equal("abc", JwtClaims.GetString(claims, "oid"));
Assert.Equal("tenant", JwtClaims.GetString(claims, "tid"));
}

[Fact]
public void GetString_TriesCandidatesInOrder()
{
var token = BuildToken("{\"preferred_username\":\"user@contoso.com\"}");
var claims = JwtClaims.TryDecodePayload(token);

Assert.Equal(
"user@contoso.com",
JwtClaims.GetString(claims, "upn", "preferred_username", "unique_name"));
}

[Fact]
public void GetString_MissingClaim_ReturnsNull()
{
var token = BuildToken("{\"oid\":\"abc\"}");
var claims = JwtClaims.TryDecodePayload(token);

Assert.Null(JwtClaims.GetString(claims, "upn"));
}

[Fact]
public void GetString_NonStringClaim_ReturnsNull()
{
var token = BuildToken("{\"oid\":42}");
var claims = JwtClaims.TryDecodePayload(token);

Assert.Null(JwtClaims.GetString(claims, "oid"));
}

private static string BuildToken(string payloadJson)
{
var payload = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
return $"header.{payload}.signature";
}

private static string Base64UrlEncode(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
Loading