Esiur CLI

This commit is contained in:
2026-07-18 00:44:14 +03:00
parent 3e8fb50110
commit ac36a3ddc7
36 changed files with 2396 additions and 304 deletions
+68
View File
@@ -0,0 +1,68 @@
using Esiur.CLI;
using Esiur.CLI.Authentication;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
namespace Esiur.CLI.Tests;
public sealed class CommandTests
{
[Fact]
public async Task ProfileUseListAndRemoveShareConfigurationService()
{
var directory = Directory.CreateTempSubdirectory("esiur-cli-command-");
try
{
var store = new ConfigurationStore(Path.Combine(directory.FullName, "config.json"));
await store.SaveAsync(new CliConfiguration
{
Profiles = new(StringComparer.OrdinalIgnoreCase)
{
["production"] = new ConnectionProfile
{
Name = "production", Endpoint = "ep://host",
},
},
}, default);
var credentials = new PromptCredentialService();
var stdout = new StringWriter();
var stderr = new StringWriter();
var app = new CliApplication(
store, credentials, new EsiurSessionFactory(credentials),
new ResourceInspectionService(), TextReader.Null, stdout, stderr);
Assert.Equal(ExitCodes.Success,
await app.RunAsync(new[] { "profile", "use", "production" }, default));
Assert.Equal("production", (await store.LoadAsync(default)).DefaultProfile);
Assert.Equal(ExitCodes.Success,
await app.RunAsync(new[] { "profile", "list", "--output", "json" }, default));
Assert.Contains("production", stdout.ToString());
Assert.Equal(ExitCodes.Success,
await app.RunAsync(new[] { "profile", "remove", "production" }, default));
var final = await store.LoadAsync(default);
Assert.Empty(final.Profiles);
Assert.Null(final.DefaultProfile);
Assert.Empty(stderr.ToString());
}
finally { directory.Delete(true); }
}
[Fact]
public async Task UnknownCommandReturnsStableInvalidArgumentsCode()
{
var directory = Directory.CreateTempSubdirectory("esiur-cli-command-");
try
{
var credentials = new PromptCredentialService();
var app = new CliApplication(
new ConfigurationStore(Path.Combine(directory.FullName, "config.json")),
credentials, new EsiurSessionFactory(credentials), new ResourceInspectionService(),
TextReader.Null, new StringWriter(), new StringWriter());
Assert.Equal(ExitCodes.InvalidArguments,
await app.RunAsync(new[] { "does-not-exist" }, default));
}
finally { directory.Delete(true); }
}
}
+101
View File
@@ -0,0 +1,101 @@
using System.Text.Json;
using Esiur.CLI;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
namespace Esiur.CLI.Tests;
public sealed class ConfigurationTests
{
[Theory]
[InlineData("ep://localhost", "ep://localhost")]
[InlineData("ep://example.test:9000/sys/service", "ep://example.test:9000")]
public void EndpointParserExtractsConnectionEndpoint(string value, string expected) =>
Assert.Equal(expected, EndpointParser.ConnectionEndpoint(value));
[Theory]
[InlineData("http://localhost")]
[InlineData("ep:///missing-host")]
[InlineData("not-an-endpoint")]
public void EndpointParserRejectsInvalidEndpoints(string value) =>
Assert.Throws<CliException>(() => ConfigurationResolver.ValidateEndpoint(value));
[Theory]
[InlineData("500ms", 500)]
[InlineData("30s", 30_000)]
[InlineData("2m", 120_000)]
public void DurationParserSupportsDocumentedSuffixes(string value, double milliseconds) =>
Assert.Equal(milliseconds, DurationParser.Parse(value).TotalMilliseconds);
[Fact]
public async Task ConfigurationRoundTripsProfilesWithoutSecrets()
{
var directory = Directory.CreateTempSubdirectory("esiur-cli-test-");
try
{
var path = Path.Combine(directory.FullName, "config.json");
var store = new ConfigurationStore(path);
await store.SaveAsync(new CliConfiguration
{
DefaultProfile = "production",
Profiles = new(StringComparer.OrdinalIgnoreCase)
{
["production"] = new ConnectionProfile
{
Name = "production",
Endpoint = "ep://host",
Provider = "password",
Identity = "ahmed",
},
},
}, default);
var text = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("\"password\":", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", text, StringComparison.OrdinalIgnoreCase);
var loaded = await store.LoadAsync(default);
Assert.Equal("production", loaded.DefaultProfile);
Assert.Equal("ep://host", loaded.Profiles["PRODUCTION"].Endpoint);
}
finally { directory.Delete(true); }
}
[Fact]
public void ExplicitOptionsOverrideSelectedProfileAndGlobals()
{
var configuration = new CliConfiguration
{
DefaultProfile = "saved",
OutputFormat = "table",
Profiles = new(StringComparer.OrdinalIgnoreCase)
{
["saved"] = new ConnectionProfile
{
Name = "saved", Endpoint = "ep://saved", OutputFormat = "raw",
Identity = "stored",
},
},
};
var result = ConfigurationResolver.Resolve(configuration,
new GlobalOptions("saved", "ep://explicit", null, "explicit", "json", TimeSpan.FromSeconds(4), false, false));
Assert.Equal("ep://explicit", result.Endpoint);
Assert.Equal("explicit", result.Identity);
Assert.Equal("json", result.OutputFormat);
Assert.Equal(TimeSpan.FromSeconds(4), result.Timeout);
}
[Theory]
[InlineData("sys/service", "sys/service")]
[InlineData("/sys/service/", "sys/service")]
public void ResourcePathsAreNormalized(string value, string expected) =>
Assert.Equal(expected, ResourceInspectionService.NormalizePath(value));
[Fact]
public void SecretsAreRedactedFromDiagnostics()
{
var value = CliApplication.RedactSecrets("password=secret token: abc123");
Assert.DoesNotContain("secret", value);
Assert.DoesNotContain("abc123", value);
Assert.Equal("password=*** token: ***", value);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Tools\Esiur.CLI\Esiur.CLI.csproj" />
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Sockets;
using Esiur.CLI.Authentication;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Stores;
namespace Esiur.CLI.Tests;
[Resource]
public partial class CliTestResource
{
[Export] public string Name { get; set; } = "Main";
[Export, ReadOnly] public bool Running { get; set; } = true;
}
public sealed class ReadOnlyIntegrationTests
{
[Fact]
public async Task QueryDescribeAndGetWorkAgainstInProcessServer()
{
var server = new Warehouse();
EsiurSession? session = null;
try
{
var port = FindAvailablePort();
await server.Put("sys", new MemoryStore());
await server.Put("sys/server", new EpServer { Port = port, AllowUnauthorizedAccess = true });
await server.Put("sys/service", new CliTestResource());
await server.Open();
var credentials = new PromptCredentialService();
var factory = new EsiurSessionFactory(credentials);
var settings = new ResolvedConnection(
"test", $"ep://localhost:{port}", null, null, null,
"json", TimeSpan.FromSeconds(10), null);
session = await factory.ConnectAsync(
settings, false, TextReader.Null, TextWriter.Null, default);
var service = new ResourceInspectionService();
var query = await service.QueryAsync(session, "sys", 1, null, default);
Assert.Contains(query, item => item.Path == "sys/service" && item.Type.Contains(nameof(CliTestResource)));
var description = await service.DescribeAsync(session, "sys/service", true, default);
Assert.Equal("sys/service", description.Path);
Assert.Contains(description.Properties, item => item.Name == nameof(CliTestResource.Name) && Equals(item.Value, "Main"));
var values = await service.GetAsync(session, "sys/service", new[] { "Name", "1" }, default);
Assert.Equal("Main", values[0].Value);
Assert.Equal(true, values[1].Value);
}
finally
{
if (session is not null) await session.DisposeAsync();
try { await server.Close(); } catch { }
}
}
static ushort FindAvailablePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = (ushort)((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Text.Json;
using Esiur.CLI;
using Esiur.CLI.Client;
using Esiur.CLI.Rendering;
namespace Esiur.CLI.Tests;
public sealed class RenderingTests
{
[Fact]
public async Task JsonOutputUsesStableCamelCaseShape()
{
var writer = new StringWriter();
await new OutputRenderer(writer).RenderAsync(
new[] { new PropertyResult("sys/service", "Name", 0, "Main") },
OutputFormat.Json, default);
using var document = JsonDocument.Parse(writer.ToString());
var value = document.RootElement[0];
Assert.Equal("sys/service", value.GetProperty("resource").GetString());
Assert.Equal("Main", value.GetProperty("value").GetString());
}
[Fact]
public async Task RawOutputPrintsOnlyPropertyValues()
{
var writer = new StringWriter();
await new OutputRenderer(writer).RenderAsync(
new[]
{
new PropertyResult("sys/service", "Name", 0, "Main"),
new PropertyResult("sys/service", "Running", 1, true),
}, OutputFormat.Raw, default);
Assert.Equal($"Main{Environment.NewLine}true{Environment.NewLine}", writer.ToString());
}
[Fact]
public async Task TableOutputContainsHeadersAndRows()
{
var writer = new StringWriter();
await new OutputRenderer(writer).RenderAsync(
new[] { new ResourceSummary("service", "sys/service", 3, "Demo.Service", "0x01", 0) },
OutputFormat.Table, default);
Assert.Contains("Session Id", writer.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.Contains("sys/service", writer.ToString());
}
[Theory]
[InlineData("table", OutputFormat.Table)]
[InlineData("json", OutputFormat.Json)]
[InlineData("jsonl", OutputFormat.JsonLines)]
[InlineData("raw", OutputFormat.Raw)]
public void OutputFormatsParse(string value, OutputFormat expected) =>
Assert.Equal(expected, OutputFormatExtensions.Parse(value));
}