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));
}
@@ -0,0 +1,122 @@
using System.ComponentModel.DataAnnotations;
using Esiur.Core;
using Esiur.Resource;
using Esiur.Stores.EntityCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Esiur.Tests.EntityCore;
public sealed class EntityCoreProviderTests
{
[Fact]
public async Task Sqlite_PersistsAndMaterializesEsiurResources()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var warehouse = new Warehouse();
var store = await warehouse.Put("database", new EntityStore());
DbContextOptions<ResourceDbContext>? options = null;
options = new DbContextOptionsBuilder<ResourceDbContext>()
.UseSqlite(connection)
.UseEsiur(store, () => new ResourceDbContext(options!))
.Options;
try
{
await warehouse.Open();
await using var context = new ResourceDbContext(options);
await context.Database.EnsureCreatedAsync();
var added = await context.Resources.AddResourceAsync(new ProviderResource
{
Name = "SQLite resource",
});
Assert.True(added.Id > 0);
Assert.NotNull(added.Instance);
Assert.Same(store, added.Instance!.Store);
context.ChangeTracker.Clear();
var loaded = await context.Resources.SingleAsync(x => x.Id == added.Id);
Assert.Same(added, loaded);
Assert.Equal("SQLite resource", loaded.Name);
Assert.NotNull(await warehouse.Get<IResource>($"database/{nameof(ProviderResource)}/{added.Id}"));
}
finally
{
if (warehouse.IsOpen)
await warehouse.Close();
}
}
[Fact]
public async Task PostgreSql_BuildsModelAndTranslatesQuery()
{
await AssertProviderCompatibility(
options => options.UseNpgsql(
"Host=localhost;Database=esiur_tests;Username=esiur;Password=unused"),
"Npgsql.EntityFrameworkCore.PostgreSQL");
}
[Fact]
public async Task MySql_BuildsModelAndTranslatesQuery()
{
await AssertProviderCompatibility(
options => options.UseMySQL(
"Server=localhost;Database=esiur_tests;User=esiur;Password=unused"),
"MySql.EntityFrameworkCore");
}
private static async Task AssertProviderCompatibility(
Action<DbContextOptionsBuilder<ResourceDbContext>> configureProvider,
string expectedProvider)
{
var warehouse = new Warehouse();
var store = await warehouse.Put("database", new EntityStore());
var builder = new DbContextOptionsBuilder<ResourceDbContext>();
configureProvider(builder);
builder.UseEsiur(store);
await using var context = new ResourceDbContext(builder.Options);
var entity = context.Model.FindEntityType(typeof(ProviderResource));
var sql = context.Resources
.Where(x => x.Name == "provider check")
.ToQueryString();
Assert.NotNull(entity);
Assert.Equal(expectedProvider, context.Database.ProviderName);
Assert.Contains(entity!.GetTableName()!, sql, StringComparison.OrdinalIgnoreCase);
}
private sealed class ResourceDbContext(DbContextOptions<ResourceDbContext> options)
: DbContext(options)
{
public DbSet<ProviderResource> Resources => Set<ProviderResource>();
}
}
public class ProviderResource : IResource
{
[Key]
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public Instance? Instance { get; set; }
public event DestroyedEvent? OnDestroy;
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
=> new(true);
public void Destroy() => OnDestroy?.Invoke(this);
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Stores\Esiur.Stores.EntityCore\Esiur.Stores.EntityCore.csproj" />
</ItemGroup>
</Project>