Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/StubTeamServer.cs
T
jaap-jan b20f7cae04
ci / build and test (pull_request) Successful in 6m27s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 15m47s
ci / api image (pull_request) Failing after 28s
Let a host marked for the relay actually be reached through it
The relay checkbox said it was not wired up, and the plan said the server
half was built and shipped. It was not: there was ADR 0004, the contract
records, RelayOptions and a CHECK constraint, and RelayOptions said in its
own remarks that there was no endpoint and no dialler. So both halves are
built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md.

Server, in Features/Relay:

- POST /api/v1/relay/tickets makes every decision: the host, Connect on
  its vault, the relay switched on, and the stored name resolved once and
  judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8,
  multicast and the metadata endpoints are refused with no configuration
  escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the
  operator's denials, allow-list and private-network switch. What passes
  goes into a Data Protection ticket, and nothing resolves the name again.
  Every target refusal is the same 403, so the endpoint cannot be used to
  map the operator's DNS; the real reason is logged.
- GET /api/v1/relay/connect takes the ticket as a second subprotocol,
  checks its expiry against TimeProvider and spends it with a unique
  insert into relay_ticket_use before accepting the upgrade. After the
  upgrade a refusal is a close code: 4502 for a host the server could not
  reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain.
- relay_session is the audit row, written from short-lived scopes.

Client:

- RelayBridge is a loopback SOCKS5 listener that accepts once and pipes
  to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so
  the connection disposes it on every way a session ends and a failed
  connect disposes it before throwing.
- ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for
  the ticket, opens the socket, starts the bridge and turns every refusal
  into a sentence.
- VaultViewModel and TransfersViewModel reach a relayed host through it,
  never fall back to dialling it directly, and replace SSH.NET's transport
  error with the relay's own reason when it gave one.

Neither side ever cancels a WebSocket operation. Cancelling one aborts
the socket, and an aborted socket sends no close frame, which is the only
way the client learns the server could not reach the host.
AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives.

Three departures from ADR 0004, recorded in it: awaited copy loops rather
than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket=
fallback, and no device id in the ticket.

Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a
real login through the bridge, API client 30, contracts 25. Not run: the
Android build (its change is copy only) and the Alpine layout oracle.
2026-09-28 14:37:20 +02:00

263 lines
9.8 KiB
C#

using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The least server a <c>VaultsViewModel</c> needs in order to be laid out with something in it.
/// </summary>
/// <remarks>
/// <para>
/// The vaults screen draws its list from the session and everything under it from the server: who is in a
/// vault and who holds a key are both read on open, and the suite's
/// <c>FakeAccountServer</c> implements <see cref="IAccountApi"/> and nothing else. Rather than teach that
/// fake five more interfaces for one screen, this serves fixed rows and refuses everything a layout test
/// has no business calling.
/// </para>
/// <para>
/// It does answer <see cref="CreateTeamVaultAsync"/>, unlike the other writes, because that is how the
/// suite gets a shared vault into the session at all — an offline layout test has no other way to reach
/// the state this screen exists to draw.
/// </para>
/// <para>
/// The rows are deliberately the <em>long</em> ones. A layout suite that measured "Bob" in a column sized
/// for an email address would certify a shape no real team produces — so the names, addresses and status
/// sentences here are at or near the length the interface can really be handed, which is what makes an
/// overflow show up at the minimum window rather than on somebody's screen.
/// </para>
/// </remarks>
internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
{
private static readonly Guid OwnerId = Guid.CreateVersion7();
private static readonly Guid ColleagueId = Guid.CreateVersion7();
private static readonly Guid TeamId = Guid.CreateVersion7();
private static readonly Guid VaultId = Guid.CreateVersion7();
/// <inheritdoc />
public Uri ServerUrl { get; } = new("https://dodossh.example");
/// <inheritdoc />
public ITeamApi Teams => this;
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <inheritdoc />
public IAccountApi Account => throw new NotSupportedException();
/// <inheritdoc />
public ISyncApi Sync => throw new NotSupportedException();
/// <inheritdoc />
public IDirectoryApi Directory => throw new NotSupportedException();
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => throw new NotSupportedException();
/// <summary>
/// A push channel that never pushes.
/// </summary>
/// <remarks>
/// Not <c>NotSupportedException</c> like its neighbours: the background synchronisation loop reads
/// this on every wait, so a layout test that opened a screen would throw from a timer thread rather
/// than draw anything. Waiting for ever is the honest stand-in — an offline layout test has no
/// server to be pushed from.
/// </remarks>
public IVaultEventStream Events => IdleVaultEventStream.Instance;
/// <inheritdoc />
public IServerRelay Relay => UnavailableServerRelay.Instance;
/// <inheritdoc />
public SyncOptions SyncOptions => new();
/// <inheritdoc />
public string? RefreshToken => null;
/// <summary>The vault whose key holders are listed, so a test can select it.</summary>
internal static Guid TeamVaultId => VaultId;
/// <summary>The membership list behind that vault, so a test can create it in the session.</summary>
internal static Guid SharedTeamId => TeamId;
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamSummary>>(
[
new TeamSummary(
TeamId,
"Platform Engineering",
"platform-engineering",
"Everything that runs the estate.",
TeamMemberRole.Owner,
MemberCount: 2,
VaultCount: 1,
DateTimeOffset.UnixEpoch),
]);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
[
new TeamMemberSummary(
OwnerId,
"alexandra.hollingsworth@dodotech.example",
"Alexandra Hollingsworth",
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch),
// The unenrolled case on purpose: its key-state phrase is the longest the column ever
// carries, and it is the row that decides whether that column is wide enough.
new TeamMemberSummary(
ColleagueId,
"bartholomew.fotheringay@dodotech.example",
"Bartholomew Fotheringay",
TeamMemberRole.Member,
TeamMemberStatus.Active,
IsEnrolled: false,
DateTimeOffset.UnixEpoch,
LastActiveAt: null),
]);
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 2,
RekeyRequired: true,
Grants:
[
new VaultGrantSummary(
OwnerId,
"alexandra.hollingsworth@dodotech.example",
"Alexandra Hollingsworth",
KeyGeneration: 2,
VaultGrantState.Active,
OwnerId,
DateTimeOffset.UnixEpoch,
RevokedAt: null),
// A generation behind, so the "stale" phrasing is the one being measured rather than
// the two-word happy case.
new VaultGrantSummary(
ColleagueId,
"bartholomew.fotheringay@dodotech.example",
"Bartholomew Fotheringay",
KeyGeneration: 1,
VaultGrantState.Active,
OwnerId,
DateTimeOffset.UnixEpoch,
RevokedAt: null),
]));
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <summary>
/// Accepts the vault, so a layout test can put a shared one into the session it is drawing.
/// </summary>
/// <remarks>
/// The client's own id and wrapped key are echoed back, exactly as the real endpoint answers: the key
/// was generated on this machine and the session adopts its own copy, so anything else here would be
/// either discarded or a vault nobody could open.
/// <para>
/// It comes back owing a rekey, which is not decoration: that is the longer of the two lines a vault row
/// can carry, and this suite exists to measure the long one.
/// </para>
/// </remarks>
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultSummary(
request.VaultId,
request.Name,
IsPersonal: false,
TeamId: teamId,
KeyGeneration: 1,
Permissions: 31,
request.WrappedVaultKey,
RekeyRequired: true));
/// <inheritdoc />
public Task<VaultSummary> RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public void Dispose()
{
// Nothing held.
}
}