Public Access
611 lines
24 KiB
C#
611 lines
24 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Net.WebSockets;
|
|
using System.Text;
|
|
using DodoSSH.Api.Features.Events;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Domain;
|
|
using DodoSSH.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace DodoSSH.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// The relay, over a real socket, through the real ticket, against a target the test owns.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The network is the only thing replaced — see <see cref="FakeRelayNetwork"/> — and it is replaced
|
|
/// <em>under</em> the address policy, so every address these tests arrange is still judged by the real
|
|
/// rules. That is what lets the refusals here be meaningful: a loopback target is refused because the
|
|
/// policy refused it, not because the fake had nothing to route it to, and the tests say which by
|
|
/// asserting that nothing was dialled.
|
|
/// </para>
|
|
/// <para>
|
|
/// The refusals share one answer on purpose, and a test holds them to it. A distinct response for "that
|
|
/// name resolves internally, but no" is a scanner for the operator's DNS in the hands of anyone who can
|
|
/// edit a host.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Collection(ApiCollection.Name)]
|
|
public sealed class RelayEndpointTests(ApiFixture fixture)
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 9, 28, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
/// <summary>How long a test waits for a frame. Generous, because it bounds a broken build, not a measurement.</summary>
|
|
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
// ---- The ticket ----
|
|
|
|
[Fact]
|
|
public async Task ATicket_NamesTheSocketAndExpiresWithinThirtySeconds()
|
|
{
|
|
var (subject, hostId) = await SeedRelayHostAsync("10.40.0.1", 22);
|
|
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
ticket.SubProtocol.ShouldBe(RelayProtocol.SubProtocol);
|
|
ticket.WebSocketUrl.AbsolutePath.ShouldBe(RelayProtocol.ConnectPath);
|
|
ticket.ExpiresAt.ShouldBeLessThanOrEqualTo(TimeProvider.System.GetUtcNow().AddSeconds(31));
|
|
|
|
// It has to stand in a Sec-WebSocket-Protocol list, so it must be an HTTP token: no padding, no
|
|
// slash, no plus — which is what base64url is for.
|
|
ticket.Ticket.ShouldAllBe(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_');
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EveryTargetRefusal_IsTheSameAnswer()
|
|
{
|
|
var (subject, relayOff) = await SeedHostAsync(relay: null);
|
|
var (_, others) = await SeedRelayHostAsync("10.40.0.2", 22);
|
|
var (_, loopback) = await SeedRelayHostAsync("127.0.0.1", 22, subject);
|
|
var (_, smtp) = await SeedRelayHostAsync("10.40.0.3", 25, subject);
|
|
var (_, unresolvable) = await SeedRelayHostAsync("nowhere.invalid", 22, subject);
|
|
|
|
var client = fixture.CreateClientFor(subject);
|
|
var answers = new List<JsonProblem>();
|
|
|
|
foreach (var hostId in new[] { relayOff, others, loopback, smtp, unresolvable, Guid.CreateVersion7() })
|
|
{
|
|
var response = await client.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
|
answers.Add((await response.Content.ReadProblemAsync()).ShouldNotBeNull());
|
|
}
|
|
|
|
answers.ShouldAllBe(problem => problem.Code == ProblemCodes.RelayTargetRejected);
|
|
answers.Select(problem => problem.Detail).Distinct(StringComparer.Ordinal).Count().ShouldBe(1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("127.0.0.1")]
|
|
[InlineData("::1")]
|
|
[InlineData("::ffff:127.0.0.1")]
|
|
[InlineData("169.254.169.254")]
|
|
[InlineData("::ffff:169.254.169.254")]
|
|
[InlineData("0.0.0.0")]
|
|
public async Task AnAddressThePolicyRefuses_IsNeverDialled(string address)
|
|
{
|
|
var (subject, hostId) = await SeedRelayHostAsync(address, 22);
|
|
var dialledBefore = fixture.RelayNetwork.Dialled.Count;
|
|
|
|
var response = await fixture.CreateClientFor(subject)
|
|
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
|
fixture.RelayNetwork.Dialled.Count.ShouldBe(dialledBefore);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANameResolvingToABadAndAGoodAddress_IsDialledOnlyOnTheGoodOne()
|
|
{
|
|
// The rebinding shape: one answer the policy likes and one it must never reach. The ticket carries
|
|
// only what passed, so the socket has nothing else it could try.
|
|
using var target = LoopbackTarget.Echo();
|
|
var good = new IPEndPoint(IPAddress.Parse("10.40.1.1"), 22);
|
|
|
|
fixture.RelayNetwork.Resolve("mixed.test", IPAddress.Parse("169.254.169.254"), good.Address);
|
|
fixture.RelayNetwork.Route(good, target.Endpoint);
|
|
|
|
var (subject, hostId) = await SeedRelayHostAsync("mixed.test", 22);
|
|
|
|
using var timeout = Timeout();
|
|
using var socket = await ConnectAsync(await IssueAsync(subject, hostId), timeout.Token);
|
|
|
|
await RoundTripAsync(socket, "hello", timeout.Token);
|
|
|
|
fixture.RelayNetwork.Dialled.ShouldNotContain(endpoint => endpoint.Address.Equals(IPAddress.Parse("169.254.169.254")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APortForward_IsRefused()
|
|
{
|
|
// Its port lives in ciphertext, so honouring one would mean taking the port from the client.
|
|
var (subject, hostId) = await SeedRelayHostAsync("10.40.0.4", 22);
|
|
|
|
var response = await fixture.CreateClientFor(subject)
|
|
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, Guid.CreateVersion7()));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
|
(await response.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.RelayTargetRejected);
|
|
}
|
|
|
|
// ---- The socket ----
|
|
|
|
[Fact]
|
|
public async Task BytesGoBothWays_AndTheSessionIsAudited()
|
|
{
|
|
using var target = LoopbackTarget.Echo();
|
|
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.2.1");
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
using var timeout = Timeout();
|
|
|
|
using (var socket = await ConnectAsync(ticket, timeout.Token))
|
|
{
|
|
socket.SubProtocol.ShouldBe(RelayProtocol.SubProtocol);
|
|
|
|
await RoundTripAsync(socket, "SSH-2.0-test", timeout.Token);
|
|
await RoundTripAsync(socket, "a second message", timeout.Token);
|
|
|
|
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, timeout.Token);
|
|
}
|
|
|
|
var session = await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token);
|
|
|
|
session.HostId.ShouldBe(hostId);
|
|
session.TargetAddress.ShouldBe("10.40.2.1");
|
|
session.TargetPort.ShouldBe(22);
|
|
session.CloseReason.ShouldBe(RelayCloseReason.ClientClosed);
|
|
|
|
var sent = Encoding.UTF8.GetByteCount("SSH-2.0-test") + Encoding.UTF8.GetByteCount("a second message");
|
|
session.BytesClientToTarget.ShouldBe(sent);
|
|
session.BytesTargetToClient.ShouldBe(sent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ATicket_IsSpentByItsFirstUse()
|
|
{
|
|
using var target = LoopbackTarget.Echo();
|
|
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.3.1");
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
using var timeout = Timeout();
|
|
using var first = await ConnectAsync(ticket, timeout.Token);
|
|
|
|
var replay = await Should.ThrowAsync<InvalidOperationException>(ConnectAsync(ticket, timeout.Token));
|
|
|
|
replay.Message.ShouldContain("401");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("not-a-ticket")]
|
|
public async Task AnUpgradeWithoutAGenuineTicket_IsRefused(string? ticket)
|
|
{
|
|
var client = fixture.Server.CreateWebSocketClient();
|
|
client.SubProtocols.Add(RelayProtocol.SubProtocol);
|
|
|
|
if (ticket is not null)
|
|
{
|
|
client.SubProtocols.Add(RelayProtocol.TicketProtocolPrefix + ticket);
|
|
}
|
|
|
|
var refused = await Should.ThrowAsync<InvalidOperationException>(client.ConnectAsync(
|
|
new Uri(fixture.Server.BaseAddress, RelayProtocol.ConnectPath.TrimStart('/')),
|
|
TestContext.Current.CancellationToken));
|
|
|
|
refused.Message.ShouldContain("401");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task APlainGet_SaysItIsAWebSocket()
|
|
{
|
|
var response = await fixture.CreateClient().GetAsync(
|
|
new Uri(RelayProtocol.ConnectPath, UriKind.Relative),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
(await response.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.MalformedRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ATargetThatDoesNotAnswer_ClosesWithItsOwnCode()
|
|
{
|
|
// Routed nowhere, so the fake refuses the connect the way a closed port does. The user has to be
|
|
// told the *server* could not reach the machine, which is not what a direct failure means.
|
|
var (subject, hostId) = await SeedRelayHostAsync("10.40.4.1", 22);
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
using var timeout = Timeout();
|
|
using var socket = await ConnectAsync(ticket, timeout.Token);
|
|
|
|
await ReadUntilCloseAsync(socket, timeout.Token);
|
|
|
|
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.TargetUnreachableCloseCode);
|
|
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
|
.ShouldBe(RelayCloseReason.TargetUnreachable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ATargetThatHangsUp_EndsTheSession()
|
|
{
|
|
using var target = LoopbackTarget.SayThenHangUp("bye");
|
|
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.5.1");
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
using var timeout = Timeout();
|
|
using var socket = await ConnectAsync(ticket, timeout.Token);
|
|
|
|
(await ReadTextAsync(socket, timeout.Token)).ShouldBe("bye");
|
|
await ReadUntilCloseAsync(socket, timeout.Token);
|
|
|
|
socket.CloseStatus.ShouldBe(WebSocketCloseStatus.NormalClosure);
|
|
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
|
.ShouldBe(RelayCloseReason.TargetClosed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnIdleSession_IsClosedWithTheIdleCode()
|
|
{
|
|
// The close code has to arrive, not merely the close. Cancelling a pending receive aborts a .NET
|
|
// WebSocket, and an aborted one sends nothing — so a relay that stopped its reader by cancelling
|
|
// it would pass a test that only checked the socket ended.
|
|
using var target = LoopbackTarget.Echo();
|
|
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.6.1");
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
using var timeout = Timeout();
|
|
using var socket = await ConnectAsync(ticket, timeout.Token);
|
|
|
|
await ReadUntilCloseAsync(socket, timeout.Token);
|
|
|
|
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.IdleTimeoutCloseCode);
|
|
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
|
.ShouldBe(RelayCloseReason.IdleTimeout);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A session is authorised once, when its ticket is issued, and used to run on for as long as it carried
|
|
/// bytes — removing somebody from a team left their shells into its machines open for up to the session's
|
|
/// maximum lifetime. The announcement that tells their event socket to re-read its vaults now has the
|
|
/// relay ask again too.
|
|
/// </para>
|
|
/// <para>
|
|
/// Access is withdrawn here by deleting the vault rather than by a team removal, because what is under
|
|
/// test is the relay hearing the announcement and asking the access model again — any way of making the
|
|
/// answer change will do, and this one needs no team. The removal path announces through the same call;
|
|
/// see <c>TeamService.RemoveMemberAsync</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ASessionWhoseUserLosesAccess_IsClosedWithItsOwnCode()
|
|
{
|
|
using var target = LoopbackTarget.Echo();
|
|
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.8.1");
|
|
var ticket = await IssueAsync(subject, hostId);
|
|
|
|
using var timeout = Timeout();
|
|
using var socket = await ConnectAsync(ticket, timeout.Token);
|
|
|
|
await RoundTripAsync(socket, "still here", timeout.Token);
|
|
|
|
Guid userId;
|
|
|
|
await using (var scope = fixture.CreateScope())
|
|
{
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
userId = (await database.Users.SingleAsync(u => u.Subject == subject, timeout.Token)).Id;
|
|
|
|
var vaultId = (await database.Hosts.SingleAsync(h => h.Id == hostId, timeout.Token)).VaultId;
|
|
var vault = await database.Vaults.SingleAsync(v => v.Id == vaultId, timeout.Token);
|
|
|
|
vault.DeletedAtUtc = Now;
|
|
await database.SaveChangesAsync(timeout.Token);
|
|
|
|
scope.ServiceProvider.GetRequiredService<IVaultEventPublisher>().VaultAccessChanged(userId);
|
|
}
|
|
|
|
await ReadUntilCloseAsync(socket, timeout.Token);
|
|
|
|
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.AccessRevokedCloseCode);
|
|
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
|
.ShouldBe(RelayCloseReason.AccessRevoked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
|
|
{
|
|
using var target = LoopbackTarget.Echo();
|
|
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.7.1");
|
|
|
|
// Three tickets while nothing is open, so the third reaches the socket and is refused there —
|
|
// the authoritative check, which the ticket endpoint's is only an early warning of.
|
|
var tickets = new[]
|
|
{
|
|
await IssueAsync(subject, hostId),
|
|
await IssueAsync(subject, hostId),
|
|
await IssueAsync(subject, hostId),
|
|
};
|
|
|
|
using var timeout = Timeout();
|
|
using var first = await ConnectAsync(tickets[0], timeout.Token);
|
|
using var second = await ConnectAsync(tickets[1], timeout.Token);
|
|
|
|
await RoundTripAsync(first, "one", timeout.Token);
|
|
await RoundTripAsync(second, "two", timeout.Token);
|
|
|
|
var early = await fixture.CreateClientFor(subject)
|
|
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
|
|
|
early.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
|
(await early.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.RelayLimitReached);
|
|
|
|
using var third = await ConnectAsync(tickets[2], timeout.Token);
|
|
await ReadUntilCloseAsync(third, timeout.Token);
|
|
|
|
third.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.TooManySessionsCloseCode);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static CancellationTokenSource Timeout()
|
|
{
|
|
var source = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
source.CancelAfter(FrameTimeout);
|
|
return source;
|
|
}
|
|
|
|
private static string NewSubject() => $"relay-{Guid.CreateVersion7():N}";
|
|
|
|
private async Task<RelayTicketResponse> IssueAsync(string subject, Guid hostId)
|
|
{
|
|
var response = await fixture.CreateClientFor(subject)
|
|
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
return (await response.Content.ReadContractAsync<RelayTicketResponse>()).ShouldNotBeNull();
|
|
}
|
|
|
|
private Task<WebSocket> ConnectAsync(RelayTicketResponse ticket, CancellationToken cancellationToken)
|
|
{
|
|
var client = fixture.Server.CreateWebSocketClient();
|
|
client.SubProtocols.Add(RelayProtocol.SubProtocol);
|
|
client.SubProtocols.Add(RelayProtocol.TicketProtocolPrefix + ticket.Ticket);
|
|
|
|
return client.ConnectAsync(
|
|
new Uri(fixture.Server.BaseAddress, RelayProtocol.ConnectPath.TrimStart('/')),
|
|
cancellationToken);
|
|
}
|
|
|
|
private static async Task RoundTripAsync(WebSocket socket, string text, CancellationToken cancellationToken)
|
|
{
|
|
await socket.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Binary, true, cancellationToken);
|
|
|
|
(await ReadTextAsync(socket, cancellationToken, Encoding.UTF8.GetByteCount(text))).ShouldBe(text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads until at least <paramref name="expected"/> bytes have arrived, because message boundaries
|
|
/// carry no meaning on the relay and an echo may come back split.
|
|
/// </summary>
|
|
private static async Task<string> ReadTextAsync(
|
|
WebSocket socket,
|
|
CancellationToken cancellationToken,
|
|
int expected = 1)
|
|
{
|
|
var buffer = new byte[64 * 1024];
|
|
var received = 0;
|
|
|
|
while (received < expected)
|
|
{
|
|
var result = await socket.ReceiveAsync(buffer.AsMemory(received), cancellationToken);
|
|
|
|
result.MessageType.ShouldBe(WebSocketMessageType.Binary);
|
|
received += result.Count;
|
|
}
|
|
|
|
return Encoding.UTF8.GetString(buffer, 0, received);
|
|
}
|
|
|
|
private static async Task ReadUntilCloseAsync(WebSocket socket, CancellationToken cancellationToken)
|
|
{
|
|
var buffer = new byte[64 * 1024];
|
|
|
|
while (true)
|
|
{
|
|
var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
|
|
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for the audit row's second write, which lands after the close frame has gone out.
|
|
/// </summary>
|
|
private async Task<RelaySession> WaitForClosedAuditAsync(Guid sessionId, CancellationToken cancellationToken)
|
|
{
|
|
while (true)
|
|
{
|
|
await using (var scope = fixture.CreateScope())
|
|
{
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
var session = await database.RelaySessions
|
|
.AsNoTracking()
|
|
.SingleOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
|
|
|
if (session?.EndedAtUtc is not null)
|
|
{
|
|
return session;
|
|
}
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
|
|
}
|
|
}
|
|
|
|
private async Task<(string Subject, Guid HostId)> SeedRoutedHostAsync(LoopbackTarget target, string address)
|
|
{
|
|
fixture.RelayNetwork.Route(new IPEndPoint(IPAddress.Parse(address), 22), target.Endpoint);
|
|
|
|
return await SeedRelayHostAsync(address, 22);
|
|
}
|
|
|
|
private Task<(string Subject, Guid HostId)> SeedRelayHostAsync(
|
|
string hostname,
|
|
int port,
|
|
string? subject = null) =>
|
|
SeedHostAsync((hostname, port), subject);
|
|
|
|
/// <summary>A user, their personal vault, and one host in it — relayed when an address is given.</summary>
|
|
private async Task<(string Subject, Guid HostId)> SeedHostAsync(
|
|
(string Hostname, int Port)? relay,
|
|
string? subject = null)
|
|
{
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var user = await FindOrAddUserAsync(database, subject);
|
|
|
|
var vault = await database.Vaults.FirstOrDefaultAsync(
|
|
v => v.OwnerUserId == user.Id,
|
|
TestContext.Current.CancellationToken)
|
|
?? database.Vaults.Add(new Vault
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
Name = "Personal",
|
|
OwnerKind = VaultOwnerKind.Personal,
|
|
OwnerUserId = user.Id,
|
|
KeyGeneration = 1,
|
|
CreatedAtUtc = Now,
|
|
UpdatedAtUtc = Now,
|
|
}).Entity;
|
|
|
|
var host = new SshHost
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
VaultId = vault.Id,
|
|
Payload = [1, 2, 3],
|
|
KeyGeneration = 1,
|
|
PayloadAadVersion = 1,
|
|
RelayEnabled = relay is not null,
|
|
Hostname = relay?.Hostname,
|
|
Port = relay?.Port,
|
|
Version = 1,
|
|
CreatedAtUtc = Now,
|
|
UpdatedAtUtc = Now,
|
|
CreatedByUserId = user.Id,
|
|
UpdatedByUserId = user.Id,
|
|
};
|
|
|
|
database.Hosts.Add(host);
|
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
|
|
return (user.Subject, host.Id);
|
|
}
|
|
|
|
private async Task<UserAccount> FindOrAddUserAsync(DodoDbContext database, string? subject)
|
|
{
|
|
if (subject is not null
|
|
&& await database.Users.SingleOrDefaultAsync(
|
|
u => u.Subject == subject,
|
|
TestContext.Current.CancellationToken) is { } existing)
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
var user = new UserAccount
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
Issuer = fixture.IdentityProvider.Authority,
|
|
Subject = NewSubject(),
|
|
Status = UserStatus.Active,
|
|
CreatedAtUtc = Now,
|
|
UpdatedAtUtc = Now,
|
|
};
|
|
|
|
database.Users.Add(user);
|
|
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
|
|
|
|
return user;
|
|
}
|
|
|
|
/// <summary>A TCP server on loopback, standing in for the machine the relay reaches.</summary>
|
|
private sealed class LoopbackTarget : IDisposable
|
|
{
|
|
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
|
|
private readonly CancellationTokenSource stopping = new();
|
|
|
|
private LoopbackTarget(Func<NetworkStream, CancellationToken, Task> serve)
|
|
{
|
|
listener.Start();
|
|
_ = AcceptAsync(serve);
|
|
}
|
|
|
|
internal IPEndPoint Endpoint => (IPEndPoint)listener.LocalEndpoint;
|
|
|
|
/// <summary>Writes back everything it reads, on every connection.</summary>
|
|
internal static LoopbackTarget Echo() => new(static async (stream, token) =>
|
|
{
|
|
var buffer = new byte[16 * 1024];
|
|
int read;
|
|
|
|
while ((read = await stream.ReadAsync(buffer, token)) > 0)
|
|
{
|
|
await stream.WriteAsync(buffer.AsMemory(0, read), token);
|
|
}
|
|
});
|
|
|
|
/// <summary>Writes one line and closes, as a server does that refuses the client.</summary>
|
|
internal static LoopbackTarget SayThenHangUp(string text) => new(async (stream, token) =>
|
|
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), token));
|
|
|
|
public void Dispose()
|
|
{
|
|
stopping.Cancel();
|
|
listener.Stop();
|
|
stopping.Dispose();
|
|
}
|
|
|
|
private async Task AcceptAsync(Func<NetworkStream, CancellationToken, Task> serve)
|
|
{
|
|
try
|
|
{
|
|
while (!stopping.IsCancellationRequested)
|
|
{
|
|
var client = await listener.AcceptTcpClientAsync(stopping.Token);
|
|
|
|
_ = Task.Run(
|
|
async () =>
|
|
{
|
|
using (client)
|
|
{
|
|
try
|
|
{
|
|
await serve(client.GetStream(), stopping.Token);
|
|
}
|
|
catch (Exception exception) when (exception is IOException or OperationCanceledException or ObjectDisposedException)
|
|
{
|
|
// The relay hung up, or the test is over.
|
|
}
|
|
}
|
|
},
|
|
CancellationToken.None);
|
|
}
|
|
}
|
|
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException)
|
|
{
|
|
// Stopped.
|
|
}
|
|
}
|
|
}
|
|
}
|