Public Access
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.
It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.
So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.
Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.
Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.
Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
260 lines
9.7 KiB
C#
260 lines
9.7 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 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.
|
|
}
|
|
}
|