Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ProfileCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.CommandTests;

using Azure.Data.Cosmos.Shell.Commands;
using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Util;

public class ProfileCommandTests
{
[Theory]
[InlineData("command-profile-description")]
[InlineData("command-profile-description-action")]
[InlineData("command-profile-description-name")]
[InlineData("command-profile-save-missing-name")]
[InlineData("command-profile-use-missing-name")]
[InlineData("command-profile-delete-missing-name")]
[InlineData("command-profile-invalid-name")]
[InlineData("command-profile-save-not-connected")]
[InlineData("command-profile-saved")]
[InlineData("command-profile-unknown")]
[InlineData("command-profile-delete-not-found")]
[InlineData("command-profile-deleted")]
[InlineData("command-profile-unknown-action")]
[InlineData("command-profile-list-col-name")]
[InlineData("command-profile-list-col-endpoint")]
[InlineData("command-profile-list-col-mode")]
[InlineData("command-profile-list-mode-default")]
public void LocalizationKeys_AreDefined(string key)
{
Assert.False(string.IsNullOrWhiteSpace(MessageService.GetString(key)));
}

[Fact]
public async Task ExecuteAsync_NoAction_DefaultsToList()
{
using var shell = ShellInterpreter.CreateInstance();
using var userProfile = new TemporaryUserProfileScope();
var command = new ProfileCommand();

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

Assert.False(result.IsError);
Assert.True(result.IsPrinted);
}

[Fact]
public async Task ExecuteAsync_Save_InvalidName_ReturnsValidationError()
{
using var shell = ShellInterpreter.CreateInstance();
var command = new ProfileCommand { Action = "save", Name = "bad name" };

var result = await command.ExecuteAsync(shell, new CommandState(), "profile save \"bad name\"", CancellationToken.None);

var error = Assert.IsType<ErrorCommandState>(result);
Assert.Contains("Invalid profile name", error.Exception.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ExecuteAsync_Save_NotConnected_ReturnsError()
{
using var shell = ShellInterpreter.CreateInstance();
var command = new ProfileCommand { Action = "save", Name = "dev" };

var result = await command.ExecuteAsync(shell, new CommandState(), "profile save dev", CancellationToken.None);

var error = Assert.IsType<ErrorCommandState>(result);
Assert.Contains("Not connected", error.Exception.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ExecuteAsync_Use_InvalidName_ReturnsValidationError()
{
using var shell = ShellInterpreter.CreateInstance();
var command = new ProfileCommand { Action = "use", Name = "bad name" };

var result = await command.ExecuteAsync(shell, new CommandState(), "profile use \"bad name\"", CancellationToken.None);

var error = Assert.IsType<ErrorCommandState>(result);
Assert.Contains("Invalid profile name", error.Exception.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ExecuteAsync_Delete_InvalidName_ReturnsValidationError()
{
using var shell = ShellInterpreter.CreateInstance();
var command = new ProfileCommand { Action = "delete", Name = "bad name" };

var result = await command.ExecuteAsync(shell, new CommandState(), "profile delete \"bad name\"", CancellationToken.None);

var error = Assert.IsType<ErrorCommandState>(result);
Assert.Contains("Invalid profile name", error.Exception.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ExecuteAsync_Delete_MissingProfile_ReturnsError()
{
using var shell = ShellInterpreter.CreateInstance();
using var userProfile = new TemporaryUserProfileScope();
var command = new ProfileCommand { Action = "delete", Name = "missing-profile" };

var result = await command.ExecuteAsync(shell, new CommandState(), "profile delete missing-profile", CancellationToken.None);

var error = Assert.IsType<ErrorCommandState>(result);
Assert.Contains("was not found", error.Exception.Message, StringComparison.OrdinalIgnoreCase);
}

private sealed class TemporaryUserProfileScope : IDisposable
{
private readonly string? originalUserProfile;
private readonly string? originalHome;
private readonly string temporaryPath;

public TemporaryUserProfileScope()
{
this.originalUserProfile = Environment.GetEnvironmentVariable("USERPROFILE");
this.originalHome = Environment.GetEnvironmentVariable("HOME");
this.temporaryPath = Path.Combine(Path.GetTempPath(), $"cosmosdbshell-profile-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.temporaryPath);
Environment.SetEnvironmentVariable("USERPROFILE", this.temporaryPath);
Environment.SetEnvironmentVariable("HOME", this.temporaryPath);
}

public void Dispose()
{
Environment.SetEnvironmentVariable("USERPROFILE", this.originalUserProfile);
Environment.SetEnvironmentVariable("HOME", this.originalHome);
if (Directory.Exists(this.temporaryPath))
{
Directory.Delete(this.temporaryPath, recursive: true);
}
}
}
}
99 changes: 99 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ProfileManagerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.CommandTests;

using Azure.Data.Cosmos.Shell.Core;

public class ProfileManagerTests
{
[Fact]
public void ListProfiles_MalformedJson_ReturnsEmpty()
{
using var userProfile = new TemporaryUserProfileScope();
var profileFilePath = GetProfileFilePath();
Directory.CreateDirectory(Path.GetDirectoryName(profileFilePath)!);
File.WriteAllText(profileFilePath, "{not json");

var profiles = ProfileManager.ListProfiles();

Assert.Empty(profiles);
}

[Fact]
public void ListProfiles_DuplicateCaseKeys_DoesNotThrowAndNormalizes()
{
using var userProfile = new TemporaryUserProfileScope();
var profileFilePath = GetProfileFilePath();
Directory.CreateDirectory(Path.GetDirectoryName(profileFilePath)!);
File.WriteAllText(profileFilePath, "{\"Dev\":{\"Endpoint\":\"https://first:443/\",\"Mode\":\"direct\"},\"dev\":{\"Endpoint\":\"https://second:443/\",\"Mode\":\"gateway\"}}");

var profiles = ProfileManager.ListProfiles();

Assert.Single(profiles);
Assert.True(profiles.TryGetValue("DEV", out var profile));
Assert.Equal("https://second:443/", profile.Endpoint);
}

[Fact]
public void DeleteProfile_ReturnsFalse_WhenMissing()
{
using var userProfile = new TemporaryUserProfileScope();

var deleted = ProfileManager.DeleteProfile("missing");

Assert.False(deleted);
}

[Fact]
public void SaveProfile_ThenDeleteProfile_ReturnsTrue()
{
using var userProfile = new TemporaryUserProfileScope();
ProfileManager.SaveProfile("dev", new ConnectionProfile
{
Endpoint = "https://example.documents.azure.com:443/",
Mode = "gateway",
});

var deleted = ProfileManager.DeleteProfile("dev");

Assert.True(deleted);
Assert.Null(ProfileManager.GetProfile("dev"));
}

private static string GetProfileFilePath()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cosmosdbshell",
"profiles.json");
}

private sealed class TemporaryUserProfileScope : IDisposable
{
private readonly string? originalUserProfile;
private readonly string? originalHome;
private readonly string temporaryPath;

public TemporaryUserProfileScope()
{
this.originalUserProfile = Environment.GetEnvironmentVariable("USERPROFILE");
this.originalHome = Environment.GetEnvironmentVariable("HOME");
this.temporaryPath = Path.Combine(Path.GetTempPath(), $"cosmosdbshell-profile-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.temporaryPath);
Environment.SetEnvironmentVariable("USERPROFILE", this.temporaryPath);
Environment.SetEnvironmentVariable("HOME", this.temporaryPath);
}

public void Dispose()
{
Environment.SetEnvironmentVariable("USERPROFILE", this.originalUserProfile);
Environment.SetEnvironmentVariable("HOME", this.originalHome);
if (Directory.Exists(this.temporaryPath))
{
Directory.Delete(this.temporaryPath, recursive: true);
}
}
}
}
48 changes: 47 additions & 1 deletion CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,53 @@ internal static void AskForRBacPermissions(string principalId, string permission
ShellInterpreter.WriteLine(MessageService.GetArgsString("command-connect-rbac-error", "id", principalId, "permission", permission));
}

/// <summary>
/// Helper used by profile "use" to connect using a saved profile.
/// </summary>
internal static async Task<CommandState> ExecuteProfileAsync(Core.ConnectionProfile profile, ShellInterpreter shell, CancellationToken token)
{
var commandState = new CommandState();
try
{
// Profiles persist endpoint/mode only, so reconnect uses current credential context.
if (string.IsNullOrWhiteSpace(profile.Endpoint))
{
throw new CommandException("profile", "Saved profile is missing an endpoint. Re-save the profile.");
}
Comment on lines +154 to +157

ConnectionMode? connectionMode = null;
if (!string.IsNullOrWhiteSpace(profile.Mode))
{
if (profile.Mode.Equals("direct", StringComparison.OrdinalIgnoreCase))
{
connectionMode = ConnectionMode.Direct;
}
else if (profile.Mode.Equals("gateway", StringComparison.OrdinalIgnoreCase))
{
connectionMode = ConnectionMode.Gateway;
}
else
{
throw new CommandException("profile", $"Invalid mode '{profile.Mode}'. Re-save the profile.");
}
Comment on lines +170 to +173
}

await shell.ConnectAsync(profile.Endpoint, null, connectionMode, token: token);
commandState.IsPrinted = true;
// Align JSON contract with other connect paths which use the "connected state" key for connect-with-endpoint results.
commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new Dictionary<string, string?> { ["connected state"] = profile.Endpoint }));
return commandState;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (Exception e)
{
throw new CommandException("profile", e);
}
}

/// <summary>
/// Prints a short usage block with examples taken from the <see cref="CosmosExampleAttribute"/>
/// metadata on this command. Shown when the user runs <c>connect</c> without arguments while
Expand Down Expand Up @@ -199,7 +246,6 @@ private static async Task<CommandState> PrintConnectionInfoAsync(ShellInterprete
}

var client = connectedState.Client;

token.ThrowIfCancellationRequested();
var acc = await client.ReadAccountAsync().WaitAsync(token);
AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title")));
Expand Down
Loading