Build Charac server and client with OIDC, SSH sessions, and packages
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user