mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
Esiur CLI
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user