Build Charac server and client with OIDC, SSH sessions, and packages
Verify / verify (ubuntu-latest, linux-bionic-arm64) (push) Canceled after 0s
Verify / verify (ubuntu-latest, linux-x64) (push) Canceled after 0s
Verify / verify (windows-latest, win-x64) (push) Canceled after 0s
Verify / postgres (push) Canceled after 0s

This commit is contained in:
Nitload
2026-10-05 14:02:50 +08:00
parent 4bc3552167
commit 0ea679932e
185 changed files with 10653 additions and 772 deletions
@@ -0,0 +1,81 @@
using System.Security.Cryptography;
using Charac.Server.Authentication;
using Charac.Server.Connections;
using Charac.Client;
namespace Charac.Server.Tests;
public sealed class ActiveClientDevicesTests
{
private static readonly ExternalIdentity Alice = new("https://auth.example/", "alice");
private static readonly ExternalIdentity Bob = new("https://auth.example/", "bob");
[Fact]
public void SameInstallationCanHoldSeveralConnectionsWhileAnotherIsRejected()
{
var devices = new ActiveClientDevices();
var first = Credential();
var second = Credential();
Assert.Equal(ClientDeviceStatus.Invalid, devices.Check(Alice, "spoofed"));
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(Alice, first, out var one));
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(Alice, first, out var two));
Assert.Equal(ClientDeviceStatus.Occupied, devices.Check(Alice, second));
Assert.Equal(ClientDeviceStatus.Occupied, devices.Acquire(Alice, second, out var rejected));
Assert.Null(rejected);
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(Bob, second, out var bob));
one!.Dispose();
Assert.Equal(ClientDeviceStatus.Occupied, devices.Check(Alice, second));
two!.Dispose();
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(Alice, second, out var replacement));
replacement!.Dispose();
bob!.Dispose();
}
[Fact]
public void OperatorDisconnectReleasesDeviceAndStaleLeaseCannotReleaseReplacement()
{
var devices = new ActiveClientDevices();
var first = Credential();
var second = Credential();
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(Alice, first, out var old));
using (devices.BeginDisconnect(Alice))
{
Assert.Equal(ClientDeviceStatus.Occupied, devices.Check(Alice, second));
Assert.False(devices.TryUse(old!, () => new object(), out _));
}
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(Alice, second, out var current));
old!.Dispose();
Assert.Equal(ClientDeviceStatus.Occupied, devices.Check(Alice, first));
current!.Dispose();
}
[Fact]
public async Task InstallationCredentialPersistsAndIsPrivate()
{
var directory = Path.Combine(Path.GetTempPath(), "rac-device-" + Guid.NewGuid().ToString("N"));
try
{
var concurrent = await Task.WhenAll(Enumerable.Range(0, 8)
.Select(_ => Task.Run(() => ClientDeviceCredential.LoadOrCreate(directory))));
var first = concurrent[0];
Assert.Equal(43, first.Length);
Assert.Equal(first, ClientDeviceCredential.LoadOrCreate(directory));
Assert.All(concurrent, value => Assert.Equal(first, value));
if (!OperatingSystem.IsWindows())
{
var mode = File.GetUnixFileMode(Path.Combine(directory, "device-key"));
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode &
(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead |
UnixFileMode.GroupWrite | UnixFileMode.OtherRead | UnixFileMode.OtherWrite));
}
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
private static string Credential() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,27 @@
using Charac.Client;
namespace Charac.Server.Tests;
public sealed class ClientConfigurationTests
{
[Fact]
public void LoadsHttpsServerAndRejectsUnsafeOrAmbiguousSettings()
{
var path = Path.Combine(Path.GetTempPath(), "rac-client-config-" + Guid.NewGuid().ToString("N"));
try
{
File.WriteAllText(path, "; per-user endpoint\n[client]\nserver=\"https://access.example.com\"\n");
Assert.Equal("https://access.example.com/", ClientConfiguration.LoadServer(path));
File.WriteAllText(path, "[client]\nserver=http://access.example.com\n");
Assert.Throws<InvalidOperationException>(() => ClientConfiguration.LoadServer(path));
File.WriteAllText(path, "[client]\nserver=https://one.example\nserver=https://two.example\n");
Assert.Throws<InvalidOperationException>(() => ClientConfiguration.LoadServer(path));
}
finally
{
File.Delete(path);
}
}
}
@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
using Charac.Server.Authentication;
using Charac.Server.Connections;
using Charac.Server.Hosting;
using Charac.Server.Ssh;
namespace Charac.Server.Tests;
public sealed class ClientResourceTests
{
[Fact]
public void SnapshotFiltersOwnerIssuerRevokedTargetAndChangedAccount()
{
var owner = new ExternalIdentity("https://auth.example/", "alice");
var target = new SshTargetResource(Guid.NewGuid(), "Fedora", "alice");
var now = DateTimeOffset.UtcNow;
var detached = new SshWorkspaceSnapshot(Guid.NewGuid(), target.Id, owner, "alice", now, "detached");
var attached = detached with { Id = Guid.NewGuid(), CreatedAt = now.AddMinutes(-1), State = "attached" };
var snapshots = new[]
{
detached, attached,
detached with { Owner = new(owner.Issuer, "bob") },
detached with { Owner = new("https://another.example/", owner.Subject) },
detached with { TargetId = Guid.NewGuid() },
detached with { Account = "previous-account" }
};
var resources = ClientResourceRoutes.CreateSnapshot(owner, [target], snapshots);
Assert.Equal(new[] { attached.Id, detached.Id }, resources.Sessions.Select(x => x.Id));
Assert.All(resources.Sessions, session => Assert.Equal("Fedora", session.TargetName));
Assert.Empty(ClientResourceRoutes.CreateSnapshot(owner, [], snapshots).Sessions);
Assert.Empty(ClientResourceRoutes.CreateSnapshot(owner, [target with { Account = "new-account" }], snapshots).Sessions);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(resources, JsonSerializerOptions.Web));
var sessionProperties = json.RootElement.GetProperty("sessions")[0].EnumerateObject().Select(x => x.Name);
Assert.Equal(new[] { "id", "targetId", "targetName", "account", "createdAt", "state" }, sessionProperties);
Assert.Equal(new[] { "id", "name", "account" },
json.RootElement.GetProperty("targets")[0].EnumerateObject().Select(x => x.Name));
}
[Fact]
public async Task AnonymousResourceRequestIsRejectedBeforeReadingDatabase()
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddScoped<SshAccessResolver>(_ => throw new InvalidOperationException("Database must not be resolved."));
builder.Services.AddSingleton<SshWorkspaceManager>();
builder.Services.AddSingleton<ActiveClientDevices>();
await using var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
ClientResourceRoutes.Map(app);
await app.StartAsync(timeout.Token);
try
{
using var http = new HttpClient();
using var response = await http.GetAsync(new Uri(new Uri(app.Urls.Single()), "/resources"), timeout.Token);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
finally
{
await app.StopAsync(CancellationToken.None);
}
}
}
@@ -0,0 +1,121 @@
using System.Net;
using System.Net.WebSockets;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Charac.Server.Authentication;
using Charac.Server.Connections;
using Charac.Server.Data;
using Charac.Server.Hosting;
using Charac.Server.Ssh;
using Charac.Client;
namespace Charac.Server.Tests;
public sealed class ClientSessionTransportTests
{
[Theory]
[InlineData("/session", "integration-test-token")]
[InlineData("/local/session", null)]
public async Task ConnectSendsBearerOnlyInHeaderAndReceivesOutput(string path, string? token)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
await using var app = builder.Build();
app.UseWebSockets();
var observed = new TaskCompletionSource<(string Authorization, string Query, string Device)>();
var closed = new TaskCompletionSource<WebSocketMessageType>();
app.MapGet(path, async context =>
{
observed.TrySetResult((context.Request.Headers.Authorization.ToString(), context.Request.QueryString.Value!,
context.Request.Headers[ClientDeviceCredential.HeaderName].ToString()));
if (path == "/session" && context.Request.Headers.Authorization != "Bearer integration-test-token")
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
using var peer = await context.WebSockets.AcceptWebSocketAsync();
await peer.SendAsync(Encoding.UTF8.GetBytes("remote output"), WebSocketMessageType.Text, true, timeout.Token);
var frame = await peer.ReceiveAsync(new byte[256], timeout.Token);
closed.TrySetResult(frame.MessageType);
await peer.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Detached", timeout.Token);
});
await app.StartAsync(timeout.Token);
try
{
var id = Guid.NewGuid();
var device = token is null ? null : new string('A', 43);
using var socket = await WorkspaceClient.OpenSessionSocketAsync(new Uri(app.Urls.Single()), id,
token, path, timeout.Token, device);
var request = await observed.Task.WaitAsync(timeout.Token);
Assert.Equal(token is null ? "" : "Bearer " + token, request.Authorization);
Assert.Equal($"?id={id}", request.Query);
Assert.Equal(device ?? "", request.Device);
var bytes = new byte[256];
var output = await socket.ReceiveAsync(bytes, timeout.Token);
Assert.Equal("remote output", Encoding.UTF8.GetString(bytes.AsSpan(0, output.Count)));
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Detached", timeout.Token);
Assert.Equal(WebSocketMessageType.Close, await closed.Task.WaitAsync(timeout.Token));
}
finally
{
await app.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task ManagedSessionGetDoesNotRequireTakeoverQuery()
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services.AddAuthorization(options => options.DefaultPolicy =
new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAssertion(_ => true).Build());
builder.Services.AddSingleton<SshWorkspaceManager>();
builder.Services.AddSingleton<ActiveClientDevices>();
builder.Services.AddScoped(_ => new SshAccessResolver(null!, TimeProvider.System));
builder.Services.AddScoped<SshBroker>(_ => null!);
builder.Services.AddScoped<AccessDbContext>(_ => null!);
await using var app = builder.Build();
app.UseWebSockets();
app.Use(async (context, next) =>
{
context.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("iss", "https://issuer.example/"), new Claim("sub", "test-user")], "test"));
await next();
});
app.UseAuthorization();
ClientSessionRoutes.Map(app);
await app.StartAsync(timeout.Token);
try
{
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
using var response = await http.GetAsync($"/session?id={Guid.NewGuid()}", timeout.Token);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
finally
{
await app.StopAsync(CancellationToken.None);
}
}
[Theory]
[InlineData("https://access.example.com", "/session", null)]
[InlineData("https://access.example.com", "/local/session", null)]
[InlineData("http://127.0.0.1:5080", "/local/session", "token")]
[InlineData("http://10.10.0.103:5080", "/session", "token")]
public async Task RejectsMissingAuthenticationAndUnsafeEndpoints(string origin, string path, string? token)
{
await Assert.ThrowsAsync<InvalidOperationException>(() => WorkspaceClient.OpenSessionSocketAsync(
new Uri(origin), Guid.NewGuid(), token, path, TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Npgsql;
using Charac.Server.Data;
namespace Charac.Server.Tests;
public sealed class DatabaseSettingsTests
{
[Fact]
public void SeparateFieldsKeepPasswordDistinctFromConnectionOptions()
{
var configuration = Settings(new Dictionary<string, string?>
{
["database:host"] = "127.0.0.1",
["database:port"] = "5432",
["database:name"] = "ni_access_test_db",
["database:username"] = "ni_access_test",
["database:password"] = "test;Maximum Pool Size=1",
["database:timeout"] = "5",
["database:maximum_pool_size"] = "100"
});
var result = DatabaseSettings.ConnectionStringFromConfiguration(configuration);
var parsed = new NpgsqlConnectionStringBuilder(result);
Assert.Equal("127.0.0.1", parsed.Host);
Assert.Equal(5432, parsed.Port);
Assert.Equal("ni_access_test_db", parsed.Database);
Assert.Equal("ni_access_test", parsed.Username);
Assert.Equal("test;Maximum Pool Size=1", parsed.Password);
Assert.Equal(5, parsed.Timeout);
Assert.Equal(100, parsed.MaxPoolSize);
}
[Fact]
public void LegacyConnectionStringCannotBeMixedWithSeparateFields()
{
var configuration = Settings(new Dictionary<string, string?>
{
["database:connection_string"] = "Host=127.0.0.1;Database=test",
["database:host"] = "localhost"
});
Assert.Throws<InvalidOperationException>(() =>
DatabaseSettings.ConnectionStringFromConfiguration(configuration));
}
[Fact]
public void PartialSeparateConfigurationFailsExplicitly()
{
var configuration = Settings(new Dictionary<string, string?>
{
["database:host"] = "127.0.0.1"
});
Assert.Throws<InvalidOperationException>(() =>
DatabaseSettings.ConnectionStringFromConfiguration(configuration));
}
private static IConfiguration Settings(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
@@ -0,0 +1,27 @@
using Charac.Server.Hosting;
namespace Charac.Server.Tests;
public sealed class LocalProbeRoutesTests
{
[Theory]
[InlineData("http://127.0.0.1:5080", true)]
[InlineData("http://[::1]:5080", true)]
[InlineData("http://0.0.0.0:5080", false)]
[InlineData("http://10.10.0.100:5080", false)]
[InlineData("https://127.0.0.1:5080", false)]
public void LocalProbeAcceptsOnlyHttpLoopbackListenAddress(string origin, bool expected)
{
Assert.Equal(expected, LocalProbeRoutes.IsLoopbackOrigin(origin));
}
[Theory]
[InlineData("127.0.0.1", true)]
[InlineData("::1", true)]
[InlineData("localhost", false)]
[InlineData("10.10.0.100", false)]
public void LocalProbeAcceptsOnlyNumericLoopbackDomain(string host, bool expected)
{
Assert.Equal(expected, LocalProbeRoutes.IsLoopbackHost(host));
}
}
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Configuration;
using Charac.Server.Authentication;
namespace Charac.Server.Tests;
public sealed class OidcSettingsTests
{
[Fact]
public void ConfiguredIssuerUsesProviderDiscoveryByDefault()
{
var settings = OidcSettings.FromConfiguration(Configuration(new Dictionary<string, string?>
{
["oidc:issuer"] = "https://auth.example.com/application/o/workspace-access/",
["oidc:client_id"] = "workspace-client"
}));
Assert.Equal("https://auth.example.com/application/o/workspace-access/", settings!.Issuer);
Assert.Equal("workspace-client", settings.ClientId);
Assert.Equal("https://auth.example.com/application/o/workspace-access/.well-known/openid-configuration",
settings.DiscoveryUrl);
}
[Fact]
public void GlobalIssuerCanUseProviderSpecificDiscovery()
{
var settings = OidcSettings.FromConfiguration(Configuration(new Dictionary<string, string?>
{
["oidc:issuer"] = "https://auth.example.com/",
["oidc:client_id"] = "workspace-client",
["oidc:discovery_url"] =
"https://auth.example.com/application/o/workspace-access/.well-known/openid-configuration"
}));
Assert.Equal("https://auth.example.com/", settings!.Issuer);
Assert.Contains("/application/o/workspace-access/", settings.DiscoveryUrl);
}
[Fact]
public void PartialOrInsecureOidcConfigurationIsRejected()
{
Assert.Null(OidcSettings.FromConfiguration(Configuration([])));
Assert.Throws<InvalidOperationException>(() => OidcSettings.FromConfiguration(
Configuration(new Dictionary<string, string?> { ["oidc:issuer"] = "https://auth.example.com/" })));
Assert.Throws<InvalidOperationException>(() => OidcSettings.FromConfiguration(
Configuration(new Dictionary<string, string?>
{
["oidc:issuer"] = "http://auth.example.com/",
["oidc:client_id"] = "workspace-client"
})));
}
private static IConfiguration Configuration(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
@@ -0,0 +1,473 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Buffers.Binary;
using System.Text;
using Microsoft.Extensions.Configuration;
using Charac.Server.Hosting;
using Charac.Server.Ssh;
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Npgsql;
using Charac.Server.Authentication;
using Charac.Server.Data;
using Charac.Server.Data.Entities;
namespace Charac.Server.Tests;
public sealed class PostgresDatabaseTests
{
public static bool HasPostgres => !string.IsNullOrWhiteSpace(
Environment.GetEnvironmentVariable("WORKSPACE_ACCESS_TEST_DATABASE"));
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task MigrationsAreRepeatableAndConstraintsAreEnforced()
{
await using var fixture = await TestDatabase.CreateAsync(verifyMigrationRepeatability: true);
await using var database = fixture.Open();
Assert.False(database.Database.HasPendingModelChanges());
Assert.Empty(await database.Database.GetPendingMigrationsAsync(TestContext.Current.CancellationToken));
var identity = new AccessIdentity { Issuer = fixture.Issuer, Subject = "alice" };
fixture.TrackIdentity(identity);
database.Identities.Add(identity);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
database.ChangeTracker.Clear();
database.Identities.Add(new AccessIdentity { Issuer = identity.Issuer, Subject = identity.Subject });
var duplicate = await Assert.ThrowsAsync<DbUpdateException>(() => database.SaveChangesAsync(TestContext.Current.CancellationToken));
Assert.Equal(PostgresErrorCodes.UniqueViolation, Assert.IsType<PostgresException>(duplicate.InnerException).SqlState);
database.ChangeTracker.Clear();
database.Grants.Add(new AccessGrant
{
IdentityId = identity.Id, TargetId = Guid.NewGuid(),
Account = "alice", CertificatePrincipal = "alice"
});
var foreign = await Assert.ThrowsAsync<DbUpdateException>(() => database.SaveChangesAsync(TestContext.Current.CancellationToken));
Assert.Equal(PostgresErrorCodes.ForeignKeyViolation, Assert.IsType<PostgresException>(foreign.InnerException).SqlState);
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task AuthorizationUsesOneQueryAndScopesIdentityAndTarget()
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync();
var counter = new QueryCounter();
await using var database = fixture.Open(counter);
var resolver = new SshAccessResolver(database, TimeProvider.System);
var identity = new ExternalIdentity(grant.Identity.Issuer, grant.Identity.Subject);
var decision = Assert.IsType<SshAccessDecision>(await resolver.ResolveAsync(identity, grant.TargetId, TestContext.Current.CancellationToken));
Assert.Equal(1, counter.Reads);
Assert.Equal(grant.Account, decision.Account);
Assert.Equal(grant.CertificatePrincipal, decision.CertificatePrincipal);
Assert.Equal(grant.Target.UserCertificateAuthority!.SigningKeyReference, decision.SigningKeyReference);
Assert.Single(decision.HostPublicKeys);
Assert.Null(await resolver.ResolveAsync(new(identity.Issuer, "bob"), grant.TargetId, TestContext.Current.CancellationToken));
Assert.Null(await resolver.ResolveAsync(new("https://different.example", identity.Subject), grant.TargetId, TestContext.Current.CancellationToken));
Assert.Null(await resolver.ResolveAsync(identity, Guid.NewGuid(), TestContext.Current.CancellationToken));
Assert.Null(await resolver.ResolveAsync(new(identity.Issuer, "alice' OR '1'='1"), grant.TargetId, TestContext.Current.CancellationToken));
Assert.Null(await resolver.ResolveAsync(identity, Guid.Empty, TestContext.Current.CancellationToken));
}
[Theory(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
[InlineData("identity")]
[InlineData("target")]
[InlineData("grant")]
[InlineData("host-key")]
[InlineData("expired")]
public async Task DisabledOrExpiredManagementDataDeniesAccess(string change)
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync();
await using (var administration = fixture.Open())
{
administration.Attach(grant);
switch (change)
{
case "identity": grant.Identity.Enabled = false; break;
case "target": grant.Target.Enabled = false; break;
case "grant": grant.Enabled = false; break;
case "host-key": grant.Target.HostKeys.ForEach(key => key.Enabled = false); break;
case "expired": grant.ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1); break;
}
await administration.SaveChangesAsync(TestContext.Current.CancellationToken);
}
await using var database = fixture.Open();
var resolver = new SshAccessResolver(database, TimeProvider.System);
Assert.Null(await resolver.ResolveAsync(new(grant.Identity.Issuer, grant.Identity.Subject), grant.TargetId, TestContext.Current.CancellationToken));
Assert.Empty(await resolver.ListTargetsAsync(new(grant.Identity.Issuer, grant.Identity.Subject), TestContext.Current.CancellationToken));
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task NextResolutionSeesRevocationWithoutReusingTrackedAuthorization()
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync();
await using var database = fixture.Open();
var resolver = new SshAccessResolver(database, TimeProvider.System);
var identity = new ExternalIdentity(grant.Identity.Issuer, grant.Identity.Subject);
Assert.NotNull(await resolver.ResolveAsync(identity, grant.TargetId, TestContext.Current.CancellationToken));
await using (var administration = fixture.Open())
{
await administration.Grants.Where(x => x.Id == grant.Id)
.ExecuteUpdateAsync(update => update.SetProperty(x => x.Enabled, false), TestContext.Current.CancellationToken);
}
Assert.Null(await resolver.ResolveAsync(identity, grant.TargetId, TestContext.Current.CancellationToken));
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task PasswordAccessDoesNotRequireCertificateAuthority()
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync(withAuthority: false);
await using var database = fixture.Open();
var resolver = new SshAccessResolver(database, TimeProvider.System);
var decision = Assert.IsType<SshAccessDecision>(await resolver.ResolveAsync(
new(grant.Identity.Issuer, grant.Identity.Subject), grant.TargetId, TestContext.Current.CancellationToken));
Assert.Equal(grant.Account, decision.Account);
Assert.Null(decision.UserCertificateAuthorityId);
Assert.Null(decision.SigningKeyReference);
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task LoginKeyBindingAndWorkspaceHistoryPersistAcrossContexts()
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync(withAuthority: false);
var key = new SshLoginKey
{
Name = "test-key-" + Guid.NewGuid().ToString("N"),
FileName = "key-" + Guid.NewGuid().ToString("N"),
Enabled = true
};
var workspaceId = Guid.CreateVersion7();
fixture.TrackLoginKey(key.Id);
fixture.TrackWorkspace(workspaceId);
await using (var administration = fixture.Open())
{
administration.LoginKeys.Add(key);
administration.WorkspaceRecords.Add(new WorkspaceRecord
{
Id = workspaceId, Issuer = grant.Identity.Issuer, Subject = grant.Identity.Subject,
TargetId = grant.TargetId, Account = grant.Account, Authentication = "key",
OpenedAt = DateTimeOffset.UtcNow
});
await administration.SaveChangesAsync(TestContext.Current.CancellationToken);
await administration.Grants.Where(x => x.Id == grant.Id)
.ExecuteUpdateAsync(update => update.SetProperty(x => x.SshLoginKeyId, key.Id),
TestContext.Current.CancellationToken);
}
await using (var fresh = fixture.Open())
{
var resolved = Assert.IsType<SshAccessDecision>(await new SshAccessResolver(fresh,
TimeProvider.System).ResolveAsync(new(grant.Identity.Issuer, grant.Identity.Subject),
grant.TargetId, TestContext.Current.CancellationToken));
Assert.Equal(key.Id, resolved.SshLoginKeyId);
Assert.Equal(key.FileName, resolved.SshLoginKeyFileName);
Assert.Equal("key", (await fresh.WorkspaceRecords.AsNoTracking()
.SingleAsync(x => x.Id == workspaceId, TestContext.Current.CancellationToken)).Authentication);
}
await using (var administration = fixture.Open())
await administration.LoginKeys.Where(x => x.Id == key.Id)
.ExecuteUpdateAsync(update => update.SetProperty(x => x.Enabled, false),
TestContext.Current.CancellationToken);
await using (var fresh = fixture.Open())
Assert.Null(await new SshAccessResolver(fresh, TimeProvider.System).ResolveAsync(
new(grant.Identity.Issuer, grant.Identity.Subject), grant.TargetId,
TestContext.Current.CancellationToken));
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task LocalRegistrationCreatesPersistentTargetAndGrantWithoutOverwritingExistingData()
{
await using var fixture = await TestDatabase.CreateAsync();
await using var database = fixture.Open();
var name = "test-managed-" + Guid.NewGuid().ToString("N");
const string algorithm = "ssh-ed25519";
var nameBytes = Encoding.ASCII.GetBytes(algorithm);
var blob = new byte[4 + nameBytes.Length + 32];
BinaryPrimitives.WriteUInt32BigEndian(blob, (uint)nameBytes.Length);
nameBytes.CopyTo(blob, 4);
blob.AsSpan(4 + nameBytes.Length).Fill(7);
var hostKey = algorithm + " " + Convert.ToBase64String(blob);
Assert.Equal(0, await AccessRegistrationCommand.RegisterTargetAsync(database,
name, "192.0.2.20", 22, hostKey));
var target = await database.Targets.SingleAsync(x => x.Name == name,
TestContext.Current.CancellationToken);
fixture.TrackTarget(target.Id);
Assert.Equal(0, await AccessRegistrationCommand.RegisterTargetAsync(database,
name, "192.0.2.20", 22, hostKey));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
AccessRegistrationCommand.RegisterTargetAsync(database, name, "192.0.2.21", 22, hostKey));
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["oidc:issuer"] = fixture.Issuer,
["oidc:client_id"] = "test-client"
}).Build();
Assert.Equal(0, await AccessRegistrationCommand.RegisterGrantAsync(database,
configuration, name, "alice", "alice-os", null));
var identity = await database.Identities.SingleAsync(x => x.Issuer == fixture.Issuer &&
x.Subject == "alice", TestContext.Current.CancellationToken);
fixture.TrackIdentity(identity);
Assert.Equal(0, await AccessRegistrationCommand.RegisterGrantAsync(database,
configuration, name, "alice", "alice-os", null));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
AccessRegistrationCommand.RegisterGrantAsync(database, configuration,
name, "alice", "other-account", null));
await using var fresh = fixture.Open();
var resolved = Assert.IsType<SshAccessDecision>(await new SshAccessResolver(fresh,
TimeProvider.System).ResolveAsync(new(fixture.Issuer, "alice"), target.Id,
TestContext.Current.CancellationToken));
Assert.Equal("alice-os", resolved.Account);
Assert.Null(resolved.SshLoginKeyId);
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task ResourceTargetsUseOneQueryAndRespectIdentityAndRevocation()
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync(withAuthority: false);
var counter = new QueryCounter();
await using var database = fixture.Open(counter);
var resolver = new SshAccessResolver(database, TimeProvider.System);
var identity = new ExternalIdentity(grant.Identity.Issuer, grant.Identity.Subject);
var target = Assert.Single(await resolver.ListTargetsAsync(identity, TestContext.Current.CancellationToken));
Assert.Equal(1, counter.Reads);
Assert.Equal(grant.TargetId, target.Id);
Assert.Equal(grant.Target.Name, target.Name);
Assert.Equal(grant.Account, target.Account);
Assert.Empty(database.ChangeTracker.Entries());
Assert.Empty(await resolver.ListTargetsAsync(new(identity.Issuer, "bob"), TestContext.Current.CancellationToken));
Assert.Empty(await resolver.ListTargetsAsync(new("https://different.example/", identity.Subject), TestContext.Current.CancellationToken));
await using (var administration = fixture.Open())
{
await administration.Grants.Where(x => x.Id == grant.Id)
.ExecuteUpdateAsync(update => update.SetProperty(x => x.Enabled, false), TestContext.Current.CancellationToken);
}
Assert.Empty(await resolver.ListTargetsAsync(identity, TestContext.Current.CancellationToken));
}
[Fact(Skip = "Set WORKSPACE_ACCESS_TEST_DATABASE for PostgreSQL integration tests.", SkipUnless = nameof(HasPostgres))]
public async Task ResourceEndpointUsesVerifiedIdentityAndRefreshSeesRevocation()
{
await using var fixture = await TestDatabase.CreateAsync();
var grant = await fixture.SeedAsync(withAuthority: false);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
var signingKey = new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32));
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = fixture.Issuer, ValidAudience = "rac-resource-test",
IssuerSigningKey = signingKey, ValidateIssuer = true, ValidateAudience = true,
ValidateLifetime = true, RequireSignedTokens = true
};
});
builder.Services.AddAuthorization();
builder.Services.AddScoped(_ => fixture.Open());
builder.Services.AddScoped<SshAccessResolver>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<SshWorkspaceManager>();
builder.Services.AddSingleton<Charac.Server.Connections.ActiveClientDevices>();
await using var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
ClientResourceRoutes.Map(app);
await app.StartAsync(timeout.Token);
try
{
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
http.DefaultRequestHeaders.Add(Charac.Server.Connections.ActiveClientDevices.HeaderName,
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'));
string Token(string subject) => new JwtSecurityTokenHandler().CreateEncodedJwt(new SecurityTokenDescriptor
{
Issuer = fixture.Issuer, Audience = "rac-resource-test",
Subject = new ClaimsIdentity([new Claim("sub", subject)]),
Expires = DateTime.UtcNow.AddMinutes(5),
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
});
var aliceToken = Token(grant.Identity.Subject);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", aliceToken);
using (var response = await http.GetAsync("/resources", timeout.Token))
{
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.CacheControl!.NoStore);
var resources = await response.Content.ReadFromJsonAsync<ClientResources>(timeout.Token);
Assert.Equal(grant.TargetId, Assert.Single(resources!.Targets).Id);
Assert.Empty(resources.Sessions);
}
var devices = app.Services.GetRequiredService<Charac.Server.Connections.ActiveClientDevices>();
var originalDevice = http.DefaultRequestHeaders.GetValues(
Charac.Server.Connections.ActiveClientDevices.HeaderName).Single();
Assert.Equal(Charac.Server.Connections.ClientDeviceStatus.Allowed,
devices.Acquire(new(fixture.Issuer, grant.Identity.Subject), originalDevice, out var deviceLease));
http.DefaultRequestHeaders.Remove(Charac.Server.Connections.ActiveClientDevices.HeaderName);
http.DefaultRequestHeaders.Add(Charac.Server.Connections.ActiveClientDevices.HeaderName,
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'));
using (var conflict = await http.GetAsync("/resources", timeout.Token))
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
deviceLease!.Dispose();
http.DefaultRequestHeaders.Remove(Charac.Server.Connections.ActiveClientDevices.HeaderName);
http.DefaultRequestHeaders.Add(Charac.Server.Connections.ActiveClientDevices.HeaderName,
originalDevice);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("bob"));
var other = await http.GetFromJsonAsync<ClientResources>("/resources?subject=alice", timeout.Token);
Assert.Empty(other!.Targets);
Assert.Empty(other.Sessions);
await using (var administration = fixture.Open())
{
await administration.Grants.Where(x => x.Id == grant.Id)
.ExecuteUpdateAsync(update => update.SetProperty(x => x.Enabled, false), timeout.Token);
}
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", aliceToken);
var revoked = await http.GetFromJsonAsync<ClientResources>("/resources", timeout.Token);
Assert.Empty(revoked!.Targets);
}
finally
{
await app.StopAsync(CancellationToken.None);
}
}
private sealed class QueryCounter : DbCommandInterceptor
{
public int Reads { get; private set; }
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData,
InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
{
Reads++;
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
private sealed class TestDatabase(string connectionString) : IAsyncDisposable
{
private Guid? _identityId;
private Guid? _authorityId;
private Guid? _loginKeyId;
private Guid? _workspaceId;
private Guid? _targetId;
public string Issuer { get; } = "https://auth.example/" + Guid.NewGuid().ToString("N");
public static async Task<TestDatabase> CreateAsync(bool verifyMigrationRepeatability = false)
{
var configured = Environment.GetEnvironmentVariable("WORKSPACE_ACCESS_TEST_DATABASE")
?? throw new InvalidOperationException("A PostgreSQL test connection is required.");
var settings = new NpgsqlConnectionStringBuilder(configured)
{
Pooling = true,
IncludeErrorDetail = false
};
if (string.IsNullOrWhiteSpace(settings.Database))
throw new InvalidOperationException("The PostgreSQL test connection must specify a dedicated database.");
var fixture = new TestDatabase(settings.ConnectionString);
await using (var database = fixture.Open())
{
database.Database.SetCommandTimeout(TimeSpan.FromMinutes(2));
await database.Database.MigrateAsync(TestContext.Current.CancellationToken);
if (verifyMigrationRepeatability)
await database.Database.MigrateAsync(TestContext.Current.CancellationToken);
}
return fixture;
}
public AccessDbContext Open(DbCommandInterceptor? interceptor = null)
{
var options = new DbContextOptionsBuilder<AccessDbContext>();
DatabaseSettings.Configure(options, connectionString);
if (interceptor is not null)
options.AddInterceptors(interceptor);
return new AccessDbContext(options.Options);
}
public void TrackIdentity(AccessIdentity identity) => _identityId = identity.Id;
public void TrackTarget(Guid id) => _targetId = id;
public void TrackLoginKey(Guid id) => _loginKeyId = id;
public void TrackWorkspace(Guid id) => _workspaceId = id;
public async Task<AccessGrant> SeedAsync(bool withAuthority = true)
{
var authority = new UserCertificateAuthority
{
Name = "test-ca-" + Guid.NewGuid().ToString("N"), PublicKey = "ssh-ed25519 test-ca-public-key",
SigningKeyReference = "test:signing-key", Enabled = true
};
var identity = new AccessIdentity
{
Issuer = Issuer, Subject = "alice", Enabled = true
};
var target = new SshTarget
{
Name = "test-target-" + Guid.NewGuid().ToString("N"), Address = "192.0.2.10",
UserCertificateAuthority = withAuthority ? authority : null,
UserCertificateAuthorityId = withAuthority ? authority.Id : null, Enabled = true
};
target.HostKeys.Add(new SshHostKey
{
TargetId = target.Id, PublicKey = "ssh-ed25519 test-host-public-key", Enabled = true
});
target.HostKeys.Add(new SshHostKey
{
TargetId = target.Id, PublicKey = "ssh-ed25519 disabled-host-public-key", Enabled = false
});
var grant = new AccessGrant
{
Identity = identity, IdentityId = identity.Id,
Target = target, TargetId = target.Id, Account = "alice",
CertificatePrincipal = "alice-terminal", Enabled = true,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};
_identityId = identity.Id;
_authorityId = withAuthority ? authority.Id : null;
_targetId = target.Id;
await using var database = Open();
database.Grants.Add(grant);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return grant;
}
public async ValueTask DisposeAsync()
{
await using var database = Open();
await using var transaction = await database.Database.BeginTransactionAsync(CancellationToken.None);
if (_workspaceId is { } workspaceId)
await database.WorkspaceRecords.Where(x => x.Id == workspaceId).ExecuteDeleteAsync(CancellationToken.None);
if (_targetId is { } targetId)
{
await database.Grants.Where(x => x.TargetId == targetId).ExecuteDeleteAsync(CancellationToken.None);
await database.HostKeys.Where(x => x.TargetId == targetId).ExecuteDeleteAsync(CancellationToken.None);
await database.Targets.Where(x => x.Id == targetId).ExecuteDeleteAsync(CancellationToken.None);
}
if (_loginKeyId is { } loginKeyId)
await database.LoginKeys.Where(x => x.Id == loginKeyId).ExecuteDeleteAsync(CancellationToken.None);
if (_identityId is { } identityId)
await database.Identities.Where(x => x.Id == identityId).ExecuteDeleteAsync(CancellationToken.None);
if (_authorityId is { } authorityId)
await database.UserCertificateAuthorities.Where(x => x.Id == authorityId).ExecuteDeleteAsync(CancellationToken.None);
await transaction.CommitAsync(CancellationToken.None);
}
}
}
@@ -0,0 +1,67 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Charac.Client;
namespace Charac.Server.Tests;
public sealed class ResourceMenuTests
{
[Fact]
public void SelectionDistinguishesExistingSessionFromNewTargetAndBlocksOccupiedSession()
{
var target = new ResourceTarget(Guid.NewGuid(), "Fedora", "alice");
var session = new ResourceSession(Guid.NewGuid(), target.Id, target.Name, target.Account,
DateTimeOffset.UtcNow, "detached");
var occupied = session with { Id = Guid.NewGuid(), State = "attached" };
var resources = new ResourceList([target], [occupied, session]);
using var output = new StringWriter();
Assert.Equal(new ResourceSelection(SessionId: session.Id),
ResourceMenu.ReadSelection(resources, new StringReader("1\n"), output));
Assert.Contains("[占用]", output.ToString());
Assert.Equal(new ResourceSelection(TargetId: target.Id),
ResourceMenu.ReadSelection(resources, new StringReader("3\n2\n"), output));
Assert.Contains("请选择列表中的编号", output.ToString());
}
[Fact]
public void EmptyResourcesAllowRefreshOrExitAndNamesCannotInjectTerminalEscapes()
{
using var output = new StringWriter();
Assert.Null(ResourceMenu.ReadSelection(new([], []), new StringReader("q\n"), output));
Assert.Contains("请管理员登记", output.ToString());
Assert.True(ResourceMenu.ReadSelection(new([], []), new StringReader("r\n"), output)!.Refresh);
Assert.Null(ResourceMenu.ReadSelection(new([], []), new StringReader(""), output));
ResourceMenu.ReadSelection(new([new(Guid.NewGuid(), "name\u001b[2J\nspoof", "alice")], []),
new StringReader("q\n"), output);
Assert.DoesNotContain("\u001b", output.ToString(), StringComparison.Ordinal);
Assert.DoesNotContain("\nspoof", output.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task RefreshFetchesNewAuthorizedResourcesWithTheSameBearerToken()
{
var target = new ResourceTarget(Guid.NewGuid(), "Fedora", "alice");
using var handler = new ResourceHandler(target);
using var http = new HttpClient(handler);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
using var output = new StringWriter();
var selected = await ResourceMenu.SelectAsync(http, new Uri("https://access.example/"),
new StringReader("r\n1\n"), output, TestContext.Current.CancellationToken);
Assert.Equal(2, handler.Requests);
Assert.Equal(new ResourceSelection(TargetId: target.Id), selected);
}
private sealed class ResourceHandler(ResourceTarget target) : HttpMessageHandler
{
public int Requests { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Assert.Equal("https://access.example/resources", request.RequestUri!.AbsoluteUri);
Assert.Equal("Bearer test-token", request.Headers.Authorization!.ToString());
Requests++;
var resources = new ResourceList(Requests == 1 ? [] : [target], []);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(resources) });
}
}
}
@@ -0,0 +1,35 @@
using Charac.Server.Hosting;
namespace Charac.Server.Tests;
public sealed class ServerCommandLineTests
{
[Fact]
public void ConfigOptionCanPrecedeOrFollowCommand()
{
var first = ServerCommandLine.Parse(["--config", "custom.config", "database", "migrate"]);
var last = ServerCommandLine.Parse(["database", "migrate", "--config=custom.config"]);
Assert.Equal("custom.config", first.ConfigPath);
Assert.Equal(["database", "migrate"], first.Command);
Assert.Equal(first.ConfigPath, last.ConfigPath);
Assert.Equal(first.Command, last.Command);
}
[Theory]
[InlineData("--config")]
[InlineData("--config=")]
[InlineData("--config", "--help")]
[InlineData("--config=a.config", "--config=b.config")]
public void MissingOrRepeatedConfigPathIsRejected(params string[] args)
{
Assert.Throws<ArgumentException>(() => ServerCommandLine.Parse(args));
}
[Fact]
public void ExplicitConfigDoesNotFallBackWhenMissing()
{
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".config");
Assert.Throws<FileNotFoundException>(() => WorkspaceAccessConfiguration.PathToLoad(missing));
}
}
@@ -0,0 +1,99 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Charac.Server.Hosting;
using Charac.Server.Connections;
using Charac.Server.Ssh;
namespace Charac.Server.Tests;
public sealed class ServerControlChannelTests
{
[Fact]
public async Task RunningProcessAcceptsStatusAndLoggingReloadButRejectsServerChange()
{
var directory = Path.Combine(Path.GetTempPath(), "workspace-access-control-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "workspace-access.config");
var original = "[server]\ndomain=127.0.0.1\nlisten=http://127.0.0.1:5080\n[Logging:LogLevel]\nDefault=Information\n";
await File.WriteAllTextAsync(path, original, TestContext.Current.CancellationToken);
var live = new ConfigurationBuilder().AddIniFile(path, optional: false, reloadOnChange: false).Build();
using var workspaces = new SshWorkspaceManager();
using var control = new ServerControlChannel(path, live, workspaces, new ActiveClientDevices(),
NullLogger<ServerControlChannel>.Instance);
try
{
await control.StartAsync(TestContext.Current.CancellationToken);
var status = await ServerControlClient.SendAsync("status", path);
Assert.True(status.Ok);
Assert.Equal(Environment.ProcessId, status.ProcessId);
Assert.Equal(0, status.Workspaces);
var sessions = await ServerControlClient.SendAsync("sessions", path);
Assert.True(sessions.Ok);
Assert.Empty(Assert.IsType<ServerControlSession[]>(sessions.Sessions));
var disabled = await ServerControlClient.SendAsync("disconnect alice", path);
Assert.False(disabled.Ok);
Assert.Null(disabled.Disconnected);
var oversized = await ServerControlClient.SendAsync(new string('x', 513), path);
Assert.False(oversized.Ok);
Assert.True((await ServerControlClient.SendAsync("status", path)).Ok);
await File.WriteAllTextAsync(path, original.Replace("Default=Information", "Default=Warning"),
TestContext.Current.CancellationToken);
var reload = await ServerControlClient.SendAsync("reload", path);
Assert.True(reload.Ok);
Assert.Equal("Warning", live["Logging:LogLevel:Default"]);
await File.WriteAllTextAsync(path, original.Replace("5080", "5081"),
TestContext.Current.CancellationToken);
var rejected = await ServerControlClient.SendAsync("reload", path);
Assert.False(rejected.Ok);
Assert.Equal("Warning", live["Logging:LogLevel:Default"]);
Assert.Equal("http://127.0.0.1:5080", live["server:listen"]);
}
finally
{
await control.StopAsync(CancellationToken.None);
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task ExplicitlyEnabledDisconnectUsesConfiguredIssuerAndValidatesSubject()
{
var directory = Path.Combine(Path.GetTempPath(), "workspace-access-control-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "workspace-access.config");
await File.WriteAllTextAsync(path,
"[management]\nallow_disconnect=true\n[oidc]\nissuer=https://auth.example/workspace/\n",
TestContext.Current.CancellationToken);
var live = new ConfigurationBuilder().AddIniFile(path, optional: false).Build();
using var workspaces = new SshWorkspaceManager();
var devices = new ActiveClientDevices();
using var control = new ServerControlChannel(path, live, workspaces, devices,
NullLogger<ServerControlChannel>.Instance);
try
{
await control.StartAsync(TestContext.Current.CancellationToken);
var identity = new Charac.Server.Authentication.ExternalIdentity(
"https://auth.example/workspace/", "alice");
var credential = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
Assert.Equal(ClientDeviceStatus.Allowed, devices.Acquire(identity, credential, out var old));
var accepted = await ServerControlClient.SendAsync("disconnect alice", path);
Assert.True(accepted.Ok);
Assert.Equal(0, accepted.Disconnected);
Assert.False(devices.TryUse(old!, () => new object(), out _));
old!.Dispose();
var rejected = await ServerControlClient.SendAsync("disconnect ", path);
Assert.False(rejected.Ok);
}
finally
{
await control.StopAsync(CancellationToken.None);
Directory.Delete(directory, recursive: true);
}
}
}
@@ -0,0 +1,58 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Charac.Server.Hosting;
namespace Charac.Server.Tests;
public sealed class ServerSourceAddressTests
{
[Fact]
public void TrustedProxyMustBeNumericIpAddress()
{
var valid = Settings("127.0.0.1");
Assert.Equal(IPAddress.Loopback, valid.TrustedProxy);
Assert.Throws<InvalidOperationException>(() => Settings("caddy.local"));
}
[Theory]
[InlineData("127.0.0.1", "203.0.113.7")]
[InlineData("10.10.0.103", "127.0.0.1")]
public async Task ForwardedClientAddressIsUsedOnlyFromConfiguredProxy(string trustedProxy, string expected)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
ServerApplication.AddTrustedProxy(builder.Services, IPAddress.Parse(trustedProxy));
await using var app = builder.Build();
app.UseForwardedHeaders();
app.MapGet("/peer", (HttpContext context) => context.Connection.RemoteIpAddress?.ToString());
await app.StartAsync(timeout.Token);
try
{
using var http = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(app.Urls.Single()), "/peer"));
request.Headers.TryAddWithoutValidation("X-Forwarded-For", "203.0.113.7");
using var response = await http.SendAsync(request, timeout.Token);
response.EnsureSuccessStatusCode();
Assert.Equal(expected, await response.Content.ReadAsStringAsync(timeout.Token));
}
finally
{
await app.StopAsync(CancellationToken.None);
}
}
private static ServerSettings Settings(string proxy) => ServerSettings.FromConfiguration(
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["server:domain"] = "127.0.0.1",
["server:listen"] = "http://127.0.0.1:5080",
["server:trusted_proxy"] = proxy
}).Build());
}
@@ -0,0 +1,104 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using Charac.Server.Hosting;
using Charac.Server.Ssh;
namespace Charac.Server.Tests;
public sealed class SessionEndProtocolTests
{
[Fact]
public async Task SshEndIsSentAfterAllOutputAndBeforeWebSocketClose()
{
var channel = Channel.CreateUnbounded<SshOutput>();
Assert.True(channel.Writer.TryWrite(new SshOutput(1, Encoding.UTF8.GetBytes("last output"))));
channel.Writer.TryComplete();
using var socket = new RecordingWebSocket();
var ended = await ClientSessionRoutes.SendOutputAsync(socket, channel.Reader,
Task.FromResult("ssh_closed"), CancellationToken.None);
Assert.True(ended);
Assert.Equal(2, socket.Messages.Count);
using var output = JsonDocument.Parse(socket.Messages[0]);
Assert.Equal("output", output.RootElement.GetProperty("type").GetString());
Assert.Equal("last output", Encoding.UTF8.GetString(Convert.FromBase64String(
output.RootElement.GetProperty("data").GetString()!)));
using var end = JsonDocument.Parse(socket.Messages[1]);
Assert.Equal("ended", end.RootElement.GetProperty("type").GetString());
Assert.Equal("ssh_closed", end.RootElement.GetProperty("reason").GetString());
Assert.True(socket.CloseOutputCalled);
}
[Fact]
public async Task ViewerReplacementDoesNotClaimSshEnded()
{
var channel = Channel.CreateUnbounded<SshOutput>();
channel.Writer.TryComplete();
using var socket = new RecordingWebSocket();
var workspaceEnd = new TaskCompletionSource<string>();
var ended = await ClientSessionRoutes.SendOutputAsync(socket, channel.Reader,
workspaceEnd.Task, CancellationToken.None);
Assert.False(ended);
Assert.Empty(socket.Messages);
Assert.False(socket.CloseOutputCalled);
}
[Fact]
public async Task WorkspaceEndDrainsFinalOutputEvenWhenDisconnectSignalWins()
{
var sender = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var receiver = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var completion = ClientSessionRoutes.CompleteStreamAsync(sender.Task, receiver.Task,
Task.CompletedTask, Task.CompletedTask, CancellationToken.None);
Assert.False(completion.IsCompleted);
sender.SetResult(true);
receiver.SetResult();
await completion;
}
[Fact]
public async Task ViewerDisconnectDoesNotWaitForWorkspaceOutput()
{
var sender = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await ClientSessionRoutes.CompleteStreamAsync(sender.Task, new TaskCompletionSource().Task,
Task.CompletedTask, ended.Task, CancellationToken.None);
Assert.False(sender.Task.IsCompleted);
}
private sealed class RecordingWebSocket : WebSocket
{
public List<byte[]> Messages { get; } = [];
public bool CloseOutputCalled { get; private set; }
public override WebSocketCloseStatus? CloseStatus => null;
public override string? CloseStatusDescription => null;
public override WebSocketState State => WebSocketState.Open;
public override string? SubProtocol => null;
public override void Abort() { }
public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription,
CancellationToken cancellationToken) => Task.CompletedTask;
public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription,
CancellationToken cancellationToken)
{
CloseOutputCalled = true;
return Task.CompletedTask;
}
public override void Dispose() { }
public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer,
CancellationToken cancellationToken) => throw new NotSupportedException();
public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType,
bool endOfMessage, CancellationToken cancellationToken)
{
Assert.Equal(WebSocketMessageType.Text, messageType);
Assert.True(endOfMessage);
Messages.Add(buffer.ToArray());
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,71 @@
using System.Buffers.Binary;
using System.Text;
using Microsoft.Extensions.Configuration;
using Charac.Server.Ssh;
using Charac.Server.Data;
namespace Charac.Server.Tests;
public sealed class SshHostKeyTrustTests
{
[Fact]
public void ExactPinnedBlobIsTrustedButDifferentBlobIsRejected()
{
var first = MakeKey(1);
var second = MakeKey(2);
var trust = new SshHostKeyTrust([first.PublicKey]);
Assert.True(trust.IsTrusted(first.Blob));
Assert.False(trust.IsTrusted(second.Blob));
}
[Fact]
public void MissingOrMalformedPinsFailClosed()
{
Assert.Throws<InvalidOperationException>(() => new SshHostKeyTrust([]));
Assert.Throws<InvalidOperationException>(() => new SshHostKeyTrust(["ssh-ed25519 invalid-base64"]));
Assert.Throws<InvalidOperationException>(() => new SshHostKeyTrust(["ssh-rsa " + MakeKey(1).PublicKey.Split(' ')[1]]));
}
[Fact]
public void ProbeConfigurationAcceptsQuotedHostPublicKey()
{
var key = MakeKey(1);
var ini = $"[ssh_probe]\naddress=10.10.0.101\nport=22\naccount=test\nhost_public_key=\"{key.PublicKey}\"\n";
using var input = new MemoryStream(Encoding.UTF8.GetBytes(ini));
var configuration = new ConfigurationBuilder().AddIniStream(input).Build();
var settings = SshProbeSettings.FromConfiguration(configuration);
Assert.Equal(key.PublicKey, settings.HostPublicKey);
}
[Fact]
public void RegistrationAcceptsOneTrustedHostKeyLineAndRejectsMultipleKeys()
{
var path = Path.Combine(Path.GetTempPath(), "rac-host-key-" + Guid.NewGuid().ToString("N") + ".pub");
try
{
var key = MakeKey(3);
File.WriteAllText(path, key.PublicKey + " host-comment\n");
Assert.Equal(key.PublicKey, AccessRegistrationCommand.ReadHostKey(path));
File.AppendAllText(path, MakeKey(4).PublicKey + "\n");
Assert.Throws<InvalidOperationException>(() => AccessRegistrationCommand.ReadHostKey(path));
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
private static (string PublicKey, byte[] Blob) MakeKey(byte marker)
{
const string algorithm = "ssh-ed25519";
var name = Encoding.ASCII.GetBytes(algorithm);
var blob = new byte[4 + name.Length + 32];
BinaryPrimitives.WriteUInt32BigEndian(blob, (uint)name.Length);
name.CopyTo(blob, 4);
blob.AsSpan(4 + name.Length).Fill(marker);
return ($"{algorithm} {Convert.ToBase64String(blob)}", blob);
}
}
@@ -0,0 +1,71 @@
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using Microsoft.Extensions.Configuration;
using Charac.Server.Ssh;
namespace Charac.Server.Tests;
public sealed class SshKeyImporterTests
{
[Fact]
public void ImportsLocalPrivateKeyWithoutOverwritingAnExistingEndpoint()
{
var root = Path.Combine(Path.GetTempPath(), "rac-key-import-" + Guid.NewGuid().ToString("N"));
var managed = Path.Combine(root, "managed");
Directory.CreateDirectory(managed);
try
{
if (OperatingSystem.IsWindows())
{
var user = WindowsIdentity.GetCurrent().User!;
var acl = new DirectorySecurity();
acl.SetOwner(user);
acl.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
acl.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None, AccessControlType.Allow));
try
{
new DirectoryInfo(managed).SetAccessControl(acl);
}
catch (UnauthorizedAccessException)
{
Assert.Skip("The test runner cannot create a private NTFS key directory.");
}
}
else
File.SetUnixFileMode(managed, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ssh_keys:directory"] = managed
}).Build();
var store = new SshKeyStore(configuration);
var importer = new SshKeyImporter(store);
var source = Path.Combine(root, "source-key");
using (var rsa = RSA.Create(2048))
File.WriteAllText(source, rsa.ExportRSAPrivateKeyPem());
var first = importer.Import("target-1", source);
Assert.Equal(first, importer.Import("target-1", source));
using (var loaded = store.Load(first))
Assert.NotNull(loaded);
using (var rsa = RSA.Create(2048))
File.WriteAllText(source, rsa.ExportRSAPrivateKeyPem());
Assert.Throws<InvalidOperationException>(() => importer.Import("target-1", source));
using var original = store.Load(first);
Assert.NotNull(original);
}
finally
{
foreach (var path in Directory.GetFiles(managed))
File.Delete(path);
Directory.Delete(managed);
foreach (var path in Directory.GetFiles(root))
File.Delete(path);
Directory.Delete(root);
}
}
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.Configuration;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using Charac.Server.Ssh;
namespace Charac.Server.Tests;
public sealed class SshKeyStoreTests
{
[Fact]
[SupportedOSPlatform("windows")]
public void WindowsAclRejectsBroadAccess()
{
if (!OperatingSystem.IsWindows())
return;
var user = WindowsIdentity.GetCurrent().User!;
var acl = new FileSecurity();
acl.SetOwner(user);
acl.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl,
AccessControlType.Allow));
SshKeyStore.CheckWindowsAcl(acl);
acl.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier("S-1-1-0"), FileSystemRights.Read,
AccessControlType.Allow));
Assert.Throws<InvalidOperationException>(() => SshKeyStore.CheckWindowsAcl(acl));
}
[Fact]
public void KeyFileNamesCannotEscapeConfiguredDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "rac-keys-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
try
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ssh_keys:directory"] = directory
}).Build();
var store = new SshKeyStore(configuration);
Assert.Equal(Path.Combine(directory, "fedora-key"), store.Resolve("fedora-key"));
Assert.Throws<InvalidOperationException>(() => store.Resolve("../other"));
Assert.Throws<InvalidOperationException>(() => store.Resolve("..\\other"));
Assert.Throws<InvalidOperationException>(() => store.Resolve(".."));
}
finally
{
Directory.Delete(directory);
}
}
}
@@ -0,0 +1,39 @@
using System.Text;
using Charac.Client;
namespace Charac.Server.Tests;
public sealed class TerminalMouseTrackingTests
{
[Fact]
public void MouseReportsReachTuiButNotShellAfterAlternateScreenEnds()
{
var tracking = new TerminalMouseTracking();
var report = Encoding.ASCII.GetBytes("\u001b[<35;88;8M");
Assert.Empty(tracking.FilterInput(report));
tracking.ObserveOutput(Encoding.ASCII.GetBytes("\u001b[?104"));
tracking.ObserveOutput(Encoding.ASCII.GetBytes("9h\u001b[?1003h\u001b[?1006h"));
Assert.True(tracking.InAlternateScreen);
Assert.Equal(report, tracking.FilterInput(report));
tracking.ObserveOutput(Encoding.ASCII.GetBytes("\u001b[?1049l"));
Assert.False(tracking.InAlternateScreen);
Assert.Empty(tracking.FilterInput(report));
Assert.Equal(Encoding.ASCII.GetBytes("\u001b[A"), tracking.FilterInput(Encoding.ASCII.GetBytes("\u001b[A")));
}
[Fact]
public void SplitInputPreservesKeyboardAndDropsStaleMouseReport()
{
var tracking = new TerminalMouseTracking();
Assert.Empty(tracking.FilterInput(Encoding.ASCII.GetBytes("\u001b[<35;88")));
Assert.True(tracking.HasPendingInput);
Assert.Empty(tracking.FilterInput(Encoding.ASCII.GetBytes(";8M")));
Assert.False(tracking.HasPendingInput);
Assert.Empty(tracking.FilterInput(Encoding.ASCII.GetBytes("\u001b")));
Assert.Equal(Encoding.ASCII.GetBytes("\u001b"), tracking.FlushPendingInput());
Assert.Equal(Encoding.ASCII.GetBytes("ab\u001b[A"), tracking.FilterInput(Encoding.ASCII.GetBytes("ab\u001b[A")));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Charac.Server.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/client/WorkspaceAccessClient.csproj" />
<ProjectReference Include="../../src/server/WorkspaceAccessServer.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using Charac.Server.Connections;
using Charac.Server.Authentication;
namespace Charac.Server.Tests;
public sealed class WorkspaceConnectionGateTests
{
[Fact]
public void OperatorDisconnectInvalidatesControllerButKeepsOwner()
{
var gate = new WorkspaceConnectionGate();
var first = Assert.IsType<ConnectionLease>(gate.Acquire(Alice).Lease);
Assert.True(gate.DisconnectCurrent());
Assert.False(gate.DisconnectCurrent());
Assert.False(gate.IsCurrent(first));
Assert.False(gate.TryDispatch(first, () => throw new Exception("Stale input was dispatched.")));
Assert.Equal(AcquisitionStatus.OwnedByAnotherIdentity,
gate.Acquire(new(Alice.Issuer, "bob-id")).Status);
Assert.Equal(AcquisitionStatus.Acquired, gate.Acquire(Alice).Status);
}
private static readonly ExternalIdentity Alice = new("https://auth.example/application/o/workspace/", "alice-id");
[Fact]
public void TakeoverInvalidatesOldInputAndDisconnect()
{
var gate = new WorkspaceConnectionGate();
var first = Assert.IsType<ConnectionLease>(gate.Acquire(Alice).Lease);
Assert.Equal(AcquisitionStatus.TakeoverRequired, gate.Acquire(Alice).Status);
var replacement = Assert.IsType<ConnectionLease>(gate.Acquire(Alice, takeover: true).Lease);
var inputs = new List<string>();
Assert.False(gate.TryDispatch(first, () => inputs.Add("stale")));
Assert.False(gate.Disconnect(first));
Assert.False(gate.CloseWorkspace(first));
Assert.True(gate.TryDispatch(replacement, () => inputs.Add("current")));
Assert.Equal(new[] { "current" }, inputs);
Assert.True(replacement.Generation > first.Generation);
}
[Fact]
public void DisconnectionPreservesOwnerAndAllowsOwnerToReconnect()
{
var gate = new WorkspaceConnectionGate();
var lease = Assert.IsType<ConnectionLease>(gate.Acquire(Alice).Lease);
Assert.True(gate.Disconnect(lease));
Assert.False(gate.IsCurrent(lease));
Assert.Equal(AcquisitionStatus.OwnedByAnotherIdentity,
gate.Acquire(new(Alice.Issuer, "bob-id"), takeover: true).Status);
Assert.Equal(AcquisitionStatus.Acquired, gate.Acquire(Alice).Status);
}
[Fact]
public void IssuerIsPartOfIdentity()
{
var gate = new WorkspaceConnectionGate();
gate.Acquire(Alice);
Assert.Equal(AcquisitionStatus.OwnedByAnotherIdentity,
gate.Acquire(new("https://other.example/", Alice.Subject), takeover: true).Status);
}
[Fact]
public void ClosedWorkspaceCanBeAssignedToAnotherOwner()
{
var gate = new WorkspaceConnectionGate();
var lease = Assert.IsType<ConnectionLease>(gate.Acquire(Alice).Lease);
Assert.True(gate.CloseWorkspace(lease));
Assert.Equal(AcquisitionStatus.Acquired, gate.Acquire(new(Alice.Issuer, "bob-id")).Status);
Assert.False(gate.IsCurrent(lease));
}
[Fact]
public void OnlyOneConcurrentConnectionAcquiresControl()
{
var gate = new WorkspaceConnectionGate();
var results = new ConcurrentBag<AcquisitionResult>();
Parallel.For(0, 64, _ => results.Add(gate.Acquire(Alice)));
Assert.Single(results, result => result.Status == AcquisitionStatus.Acquired);
Assert.Equal(63, results.Count(result => result.Status == AcquisitionStatus.TakeoverRequired));
}
[Fact]
public void LeaseFromAnotherGateCannotDispatch()
{
var gate = new WorkspaceConnectionGate();
var other = new WorkspaceConnectionGate();
gate.Acquire(Alice);
var foreign = Assert.IsType<ConnectionLease>(other.Acquire(Alice).Lease);
Assert.False(gate.TryDispatch(foreign, () => Assert.Fail("Foreign lease dispatched input.")));
}
}
@@ -0,0 +1,652 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.0.1, )",
"resolved": "18.0.1",
"contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==",
"dependencies": {
"Microsoft.CodeCoverage": "18.0.1",
"Microsoft.TestPlatform.TestHost": "18.0.1"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.5, )",
"resolved": "3.1.5",
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
},
"xunit.v3": {
"type": "Direct",
"requested": "[3.2.2, )",
"resolved": "3.2.2",
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
"dependencies": {
"xunit.v3.mtp-v1": "[3.2.2]"
}
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
},
"Microsoft.Bcl.Cryptography": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.0.1",
"contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA=="
},
"Microsoft.EntityFrameworkCore": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.12",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.12",
"Microsoft.Extensions.Caching.Memory": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ=="
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.12",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "hWWJzJpmg161rYJ2H6vxqCuFfh6zL2Tp9emOrfu1M4gsmW5BOlW8GldthyFwm2L+beW+0axmkMKRw3HyO1qceQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "OIFhLxKAdvSrZuecX177ZkN4AcAB6OLlbS8y0MQYL68512aMOZI8cJLb8OeuYe2X+gu5uMqbbCxhzAmSp+Q2ow==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "PNPS7yYH9U2M0dExn0tMkQFVtE53xTlCs/dgTfsWTTFyCF6qwGT3VrTgm2nnnkSRlSmvM9/J+n9xCpyzd9xTpQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.12",
"Microsoft.Extensions.FileProviders.Physical": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "H4u2ZeIhjmRQvbHj7S7U4MK1kXb0khx3UtOWcir32VHczdFyIVF0lvHew/VVHmCF7Uk0QseAUaO7HyFKOCSiKA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.12",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "2OtyTIt0/tkChr3inq9Y0c34xogxXW//68gvPzIWgKwyVR4x82RSGVSZfV+ZgG13zrKBzqjaI23wpUCfKpY2yQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Configuration.Json": "10.0.12",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.12",
"Microsoft.Extensions.FileProviders.Physical": "10.0.12"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "amx0O/R4dReqKlC3T740+CHpX8D8tAIV0f5kAPfFi4XK9jYo9BSepGt9/rWfvvCoSMaZ1+XvnqPp/Vv2fmTdAg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.12",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "fMp91qlRlMOuHfMyp7LuAnOS+MOeWTF1obJmyFcs4nZCXu6pr+QB1AbFJcl2qpBltBrqF/Y7+Rn9IuNcSuctig==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "iQseV6HixWzEYQH4i++84ToIroXnM5jC1TJF4EqBz3lWNG1HBTMYDLkRb6Wm3wk5V6WqSfl7NukO/LYsloGyfg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "3cY+wo2+Qe65BVwShhr2fFqZv5Ns73h3kX4mpW3WvSgfT3AWCk0hKArKW4TF7Dg3mvbJdA6RhWPW7FR+PS5/1w==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.12",
"Microsoft.Extensions.FileSystemGlobbing": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "45cV+UeI0chPAIXrx9Wv7K4PJXuuIR2pnOur5MOAo6kOFclBHSLLDrvGAxMfWRMtUvpv/kQZkxlgaUN56jwzSQ=="
},
"Microsoft.Extensions.Hosting": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "dzPj59oALFLu9TA4YUrfxqXEcbJVen/D2kOoitgM1lXBeSvPwPR8rOrrV0NKilCV3K0l4o+TLJJ3a1wmTu40Uw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Configuration.Binder": "10.0.12",
"Microsoft.Extensions.Configuration.CommandLine": "10.0.12",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.12",
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.12",
"Microsoft.Extensions.Configuration.Json": "10.0.12",
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.12",
"Microsoft.Extensions.DependencyInjection": "10.0.12",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Diagnostics": "10.0.12",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.12",
"Microsoft.Extensions.FileProviders.Physical": "10.0.12",
"Microsoft.Extensions.Hosting.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging.Configuration": "10.0.12",
"Microsoft.Extensions.Logging.Console": "10.0.12",
"Microsoft.Extensions.Logging.Debug": "10.0.12",
"Microsoft.Extensions.Logging.EventLog": "10.0.12",
"Microsoft.Extensions.Logging.EventSource": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "ZlIxIJtWmrph0Ikj0svSaTkZ+d+ZYpTAhazpf1b3aoFRmlNnNWwkcvqnqT9yKHjKvfBRthvUrkq11OxfItOhpg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.12",
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "CLHWfKQZWFE6fSzRXfjd4UlGveckkfPtwx/p5yF/o3hEkMSzGsl7MSxNAkI19hjw0rprBqrQC09Dwy6Lh6kweA==",
"dependencies": {
"Microsoft.Extensions.Configuration": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Configuration.Binder": "10.0.12",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.12"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "nHvaR8gO0CdEfG324KdUsP3Hqgdf1licZb+UlNh5pFYw7+o8QYTmiMgFmwayE/LPSpaqND/Qn3xHa1cI9b8x0w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging.Configuration": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "dEvvABq9Qgfu/SgTncXDjCshcJmgO97bLOnMAuqBDSonlk4+VXlKJMHPak+S78TlOxJ47+gY7vPgVukJsr69gg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "Qn0FWPMiQzM4GQEEXxrJbh3vp3I9wrAgpMcsVH36+6FTRGXaqtwHDgf/2030FUJ4OuNSpOeIRh+9kCZGDDsfGg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12",
"System.Diagnostics.EventLog": "10.0.12"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "nKpYoiUdBu8fdLwDxj/fjjGn//7/VwRLzujtMq99/pExrswMXOz+80hbB98QxrD8qPt9oWlFpEVrN9OBKOP7RQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Configuration.Binder": "10.0.12",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Options": "10.0.12",
"Microsoft.Extensions.Primitives": "10.0.12"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ=="
},
"Microsoft.IdentityModel.Abstractions": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw=="
},
"Microsoft.IdentityModel.JsonWebTokens": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"Microsoft.IdentityModel.Logging": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==",
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.19.2"
}
},
"Microsoft.IdentityModel.Protocols": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "1XOcyY36cVymzE3qKdzKaUEZ4Pzt7ZpSa14JZoPPK1NLFUkQDs85TCqpV6XDo0YjFXj6nVK00AfOHppjghjhtw==",
"dependencies": {
"Microsoft.IdentityModel.Protocols": "8.19.2",
"System.IdentityModel.Tokens.Jwt": "8.19.2"
}
},
"Microsoft.IdentityModel.Tokens": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.0.1",
"contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.0.1",
"contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.0.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "sOZM+VyRj1pg/ItlHsvrSbjws6oGqZveTzTNBGZ9ziwRDyKsOF3ub6vhvOdxotCIqtlnyHAIwP5sLNvgiZC+pQ=="
},
"System.IdentityModel.Tokens.Jwt": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "gqhDC/icByKEutygpr+OFgAmjwTVowyzFjWB8K0q1ww8uFj5a1BQNL+QvijUl9uhq4p8OdDwcAIrjVC+eC9FVA==",
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "8.19.2",
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"System.ServiceProcess.ServiceController": {
"type": "Transitive",
"resolved": "10.0.12",
"contentHash": "sFV+RM/yPtshiCAEIuTm8Dt4F7VuKRbL50PnGC9nXHolk/jw+z3T8zx7axKTBC8mqtQU2ZmjsoPC9b1iwOilNQ==",
"dependencies": {
"System.Diagnostics.EventLog": "10.0.12"
}
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
},
"xunit.v3.assert": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
},
"xunit.v3.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
}
},
"xunit.v3.core.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
"dependencies": {
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
"Microsoft.Testing.Platform": "1.9.1",
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.inproc.console": "[3.2.2]"
}
},
"xunit.v3.extensibility.core": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
"dependencies": {
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
"dependencies": {
"xunit.analyzers": "1.27.0",
"xunit.v3.assert": "[3.2.2]",
"xunit.v3.core.mtp-v1": "[3.2.2]"
}
},
"xunit.v3.runner.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
"dependencies": {
"Microsoft.Win32.Registry": "[5.0.0]",
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.runner.inproc.console": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
"dependencies": {
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.common": "[3.2.2]"
}
},
"charac": {
"type": "Project"
},
"char_rac_server": {
"type": "Project",
"dependencies": {
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.12, )",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.12, )",
"Microsoft.Extensions.Hosting.Systemd": "[10.0.12, )",
"Microsoft.Extensions.Hosting.WindowsServices": "[10.0.12, )",
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )",
"SSH.NET": "[2026.0.0, )"
}
},
"Microsoft.AspNetCore.Authentication.JwtBearer": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "DeE+Bq2OqB3FikFkZk+7gFBeLOQhkSolejLrbi+a9zpfvXtW1Ff6Nd6DIvHXIm+pc4aM4+52vDy3jnuau0qoUQ==",
"dependencies": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.19.2"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.12",
"Microsoft.Extensions.Caching.Memory": "10.0.12",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12"
}
},
"Microsoft.Extensions.Hosting.Systemd": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "WC1u8RbTqJTY/JknZFHX0+TDptgtpoBRc8Kro0xPSwunMql3e1zBxnK+OJbx30ozSCfMpHaYAfaeiVQgM5sizw==",
"dependencies": {
"Microsoft.Extensions.Hosting": "10.0.12"
}
},
"Microsoft.Extensions.Hosting.WindowsServices": {
"type": "CentralTransitive",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "Za5xCWb7jkHd7+hYu8C22uIf0DGhNASIiXo1xSU450nj01RFCerP06h1Bh3kxUm+5DijfgP0gtpY6v+oI/UI4g==",
"dependencies": {
"Microsoft.Extensions.Hosting": "10.0.12",
"Microsoft.Extensions.Logging.EventLog": "10.0.12",
"System.ServiceProcess.ServiceController": "10.0.12"
}
},
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"type": "CentralTransitive",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
"Npgsql": "10.0.3"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
}