Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -56,6 +56,36 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
// nothing to wrap and no signature to attribute it with. There is no search here — see
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
"GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",
// The other half of the same decision: the directory says what a key is, this is how a client
// checks that claim against a chain the server cannot rewrite without every other client
// noticing. Nothing in it is secret.
"GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",
// Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
// they have set a vault up must still be able to see the team they are now in.
"GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
"GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own.
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
"POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
// sharing graph — and the two writes are gated on Share inside the handler, which this table
// cannot see. See VaultGrantEndpoints.
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
// MapHealthChecks constrains no verb, hence ANY.
@@ -836,6 +836,269 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
}
/// <remarks>
/// Both log kinds at once, because they are the same shape and the property worth pinning is shared: an
/// entry goes up as ciphertext and comes back with no plaintext field of any sort. A log is the one thing
/// here whose <em>timestamps</em> would be genuinely useful to the server and are exactly what it must not
/// have — a <c>started_at</c> column is a record of when each user works.
/// </remarks>
[Theory]
[InlineData(SyncEntityType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry)]
public async Task ALogEntry_RoundTripsAsCiphertextWithNoPlaintextAtAll(SyncEntityType type)
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var entryId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest([LogOperation(type, entryId, expectedVersion: null, envelope: [1, 1])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [type]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(type);
change.EntityId.ShouldBe(entryId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([1, 1]);
change.PlaintextFields.ShouldBeNull("when a user connects is not something this server keeps");
}
/// <remarks>
/// <c>Kind</c> is the field a client would most plausibly fill in on an activity entry — it names which
/// sort of item the entry is about, and it is right there in the contract. A column recording that
/// somebody created four SSH keys last Tuesday describes the keychain out of facts that each look
/// harmless, so it is refused rather than dropped.
/// </remarks>
[Theory]
[InlineData(SyncEntityType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry)]
public async Task ALogEntryCarryingAnythingInTheClear_IsRejected(SyncEntityType type)
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = LogOperation(type, Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(Kind: 3, RelatedId: Guid.CreateVersion7()) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
}
/// <remarks>
/// The endpoint is the field a client might reach for, and it is the one that must not go: for everybody
/// self-hosting it is an address on their own network, which is exactly what the host table only holds in
/// the clear when the relay cannot work without it.
/// </remarks>
[Fact]
public async Task ABucket_RoundTripsAsCiphertextWithNoPlaintextAtAll()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var bucketId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest(
[BucketOperation(bucketId, expectedVersion: null, envelope: [9, 9])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [SyncEntityType.ObjectStore]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(SyncEntityType.ObjectStore);
change.EntityId.ShouldBe(bucketId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([9, 9]);
change.PlaintextFields.ShouldBeNull("where a user keeps their data is not something this server keeps");
}
[Fact]
public async Task ABucketCarryingItsEndpointInTheClear_IsRejected()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = BucketOperation(Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(RelayEnabled: true, Hostname: "minio.internal", Port: 9000) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
}
[Fact]
public async Task AHostGroup_RoundTripsAsCiphertextWithNoPlaintextAtAll()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var groupId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest(
[HostGroupOperation(groupId, expectedVersion: null, envelope: [7, 7])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [SyncEntityType.HostGroup]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(SyncEntityType.HostGroup);
change.EntityId.ShouldBe(groupId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([7, 7]);
change.PlaintextFields.ShouldBeNull(
"how a user files their machines is not something this server keeps");
}
/// <remarks>
/// <para>
/// <b>The regression guard for the decision that group membership stays encrypted.</b>
/// <c>SyncPlaintextFields.GroupId</c> cannot be removed — the wire contract is frozen — and the server
/// used to copy it into a column. Nothing ever sent one, so the column was dropped rather than migrated;
/// this is what stops a later client filling the field in and finding it silently accepted.
/// </para>
/// <para>
/// Run for a relay host as well as an ordinary one, because the check has to sit ahead of the relay branch
/// to apply to both — and the relay branch returns early on its happy path, so a check written after it
/// would pass this test in the <see langword="false"/> case and let the value through in the other.
/// </para>
/// </remarks>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AHostCarryingItsGroupInTheClear_IsRejected(bool relayEnabled)
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var fields = relayEnabled
? new SyncPlaintextFields(
RelayEnabled: true,
Hostname: "db.internal",
Port: 22,
GroupId: Guid.CreateVersion7())
: new SyncPlaintextFields(GroupId: Guid.CreateVersion7());
var operation = NewOperation(Guid.CreateVersion7(), null, [1]) with { PlaintextFields = fields };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("inside its encrypted payload");
}
[Fact]
public async Task AHostGroupCarryingAParent_IsRejected()
{
// Groups are flat. A client that grew a tree would reach for ParentId, and the refusal is what stops
// it storing one — a cycle assembled from two offline re-parents has no repair path, because the
// pointers the server would have to check are inside payloads it cannot read.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = HostGroupOperation(Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(ParentId: Guid.CreateVersion7()) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("flat");
}
[Fact]
public async Task ASnippet_RoundTripsAsCiphertextWithNoPlaintextAtAll()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var snippetId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest(
[SnippetOperation(snippetId, expectedVersion: null, envelope: [8, 8])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [SyncEntityType.Snippet]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(SyncEntityType.Snippet);
change.EntityId.ShouldBe(snippetId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([8, 8]);
change.PlaintextFields.ShouldBeNull("what a user runs is not something this server keeps");
}
[Fact]
public async Task ASnippetCarryingTheHostItIsScopedTo_IsRejected()
{
// Host scoping is not in the first version of snippets, and RelatedId is where a client would put it.
// Refused rather than dropped: the field would tell the operator which commands belong to which
// machine, which is the clustering the payload exists to keep out of their hands.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = SnippetOperation(Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(RelatedId: Guid.CreateVersion7()) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
}
[Fact]
public async Task ThreeItemTypesWithOneId_AreThreeSeparateItems()
{
@@ -1027,6 +1290,68 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
Payload(envelope),
PlaintextFields: null);
/// <remarks>
/// Narrow for the same reason as the two above, and the field it most conspicuously does not carry is the
/// one named after the type: a group's name is inside the envelope, and so is its membership.
/// </remarks>
private static SyncPushOperation HostGroupOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.HostGroup,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
private static SyncPushOperation SnippetOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.Snippet,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
/// <remarks>
/// One helper for both log kinds, because they differ by exactly one enum value — which is the point:
/// anything that were true of one and not the other would be a difference nothing else in the design
/// asks for.
/// </remarks>
private static SyncPushOperation LogOperation(
SyncEntityType type,
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
type,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
private static SyncPushOperation BucketOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.ObjectStore,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
private static SyncPushRequest NewCreateBatch() =>
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
@@ -0,0 +1,498 @@
using System.Net;
using System.Net.Http.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Teams, membership and the vault key grants that make a team vault readable.
/// </summary>
/// <remarks>
/// <para>
/// The half of M3 that runs on the server, which is the half that decides <em>what will be served</em>.
/// Whether a member can decrypt what they are served is decided by holding a key, and no test here can
/// assert it — that lives in the client suite, where a key exists. The two are separate on purpose and
/// these tests are written to keep them separate: none of them checks that a wrapped key is right,
/// because the server cannot.
/// </para>
/// <para>
/// The cases worth having are the ones where a mistake would be invisible. A member removed but still
/// served; a viewer allowed to push; a vault visible to a team it does not belong to; a grant accepted
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
/// outside.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class TeamEndpointTests(ApiFixture fixture)
{
private const string TeamsUrl = "/api/v1/teams";
private const string EnrollUrl = "/api/v1/me/enrollment";
[Fact]
public async Task CreatingATeam_MakesTheCallerItsOwner()
{
var client = await EnrolledClientAsync("team-owner");
var team = await CreateTeamAsync(client, "Platform");
team.Role.ShouldBe(TeamMemberRole.Owner);
team.MemberCount.ShouldBe(1);
team.VaultCount.ShouldBe(0);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
listed.ShouldContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// The same body twice, as a client whose response was lost would send it. Enrollment behaves this way
/// and a team create has the same shape — a client-chosen id — so it has to behave the same or a lost
/// response leaves somebody with two teams under one name.
/// </remarks>
[Fact]
public async Task RepeatingACreate_ReturnsTheSameTeamRatherThanASecondOne()
{
var client = await EnrolledClientAsync("team-retry");
var request = new CreateTeamRequest(
Guid.CreateVersion7(), "Retry", $"retry-{Guid.CreateVersion7():N}", null);
var first = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
var second = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
second.TeamId.ShouldBe(first.TeamId);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
listed.Count(row => row.TeamId == first.TeamId).ShouldBe(1);
}
[Fact]
public async Task ASlugAlreadyInUse_IsRefusedWithItsOwnCode()
{
var client = await EnrolledClientAsync("team-slug");
var slug = $"taken-{Guid.CreateVersion7():N}";
await PostAsync<CreateTeamRequest, TeamSummary>(
client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null));
var response = await client.PostContractAsync(
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
}
/// <remarks>
/// A team somebody is not in answers 404, not 403. Distinguishing them would let a caller confirm
/// which team ids exist, and team ids travel in URLs.
/// </remarks>
[Fact]
public async Task ATeamTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist()
{
var owner = await EnrolledClientAsync("team-private-owner");
var stranger = await EnrolledClientAsync("team-private-stranger");
var team = await CreateTeamAsync(owner, "Private");
var real = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
var invented = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
invented.StatusCode.ShouldBe(real.StatusCode);
}
/// <remarks>
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
/// design — the server can grant access to the ciphertext and cannot grant the ability to read it.
/// </remarks>
[Fact]
public async Task AnAddedMember_SeesTheTeamVaultWithNoKeyUntilSomebodyWrapsOne()
{
var owner = await EnrolledClientAsync("grant-owner", "owner@example.com");
var member = await EnrolledClientAsync("grant-member", "member@example.com");
var team = await CreateTeamAsync(owner, "Sharing");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "member@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);
vault.ShouldNotBeNull("membership is what makes a team vault visible");
vault.WrappedVaultKey.ShouldBeNull("and it is not what makes it readable");
vault.IsPersonal.ShouldBeFalse();
vault.TeamId.ShouldBe(team.TeamId);
}
/// <remarks>
/// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
/// role that resolved to the wrong flags would let somebody who was added to look at a vault change
/// what everybody else connects with.
/// </remarks>
[Fact]
public async Task AViewer_MayPullAndMayNotPush()
{
var owner = await EnrolledClientAsync("viewer-owner", "vowner@example.com");
var viewer = await EnrolledClientAsync("viewer-member", "viewer@example.com");
var team = await CreateTeamAsync(owner, "Read only");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "viewer@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Viewer);
var pull = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
pull.StatusCode.ShouldBe(HttpStatusCode.OK);
var push = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await push.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.Forbidden);
}
/// <remarks>
/// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what
/// is <em>not</em> asserted — that they have forgotten anything. They have not, and ADR 0001 says so.
/// </remarks>
[Fact]
public async Task ARemovedMember_StopsBeingServedTheTeamsVault()
{
var owner = await EnrolledClientAsync("removal-owner", "rowner@example.com");
var member = await EnrolledClientAsync("removal-member", "rmember@example.com");
var team = await CreateTeamAsync(owner, "Departures");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "rmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
var removed = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
var pull = await member.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
pull.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// Removing a member leaves the vault flagged for rekey, which is a promise the server records and
/// cannot keep on its own: rekeying re-wraps every item's data key and only a client holding the
/// current one can do that. The flag is what the interface reads to say so; M5 is what acts on it.
/// </remarks>
[Fact]
public async Task RemovingAMember_FlagsTheTeamsVaultsForRekey()
{
var owner = await EnrolledClientAsync("rekey-owner", "kowner@example.com");
await EnrolledClientAsync("rekey-member", "kmember@example.com");
var team = await CreateTeamAsync(owner, "Rekeys");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "kmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.RekeyRequired.ShouldBeTrue();
}
/// <remarks>
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. Refused
/// with a code the client can act on rather than a bare 400, since "you cannot do that" and "you did
/// that wrong" lead somewhere different.
/// </remarks>
[Fact]
public async Task TheOwner_CannotBeRemoved()
{
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
var team = await CreateTeamAsync(owner, "Sole");
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
var response = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
}
/// <remarks>
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
/// listing a grant whose holder cannot fetch anything is worse than an error.
/// </remarks>
[Fact]
public async Task AGrantToANonMember_IsRefused()
{
var owner = await EnrolledClientAsync("outsider-owner", "oowner@example.com");
await EnrolledClientAsync("outsider", "outsider@example.com");
var team = await CreateTeamAsync(owner, "Closed");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "outsider@example.com");
var response = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants",
new IssueVaultGrantRequest(
entry.UserId,
entry.Fingerprint,
KeyGeneration: 1,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// A fingerprint that is not the recipient's current key is refused. The server cannot tell whether
/// the wrap contains the right key — nothing on that machine can — but it can tell that this grant
/// was made for a key nobody holds, which would otherwise surface at the far end days later as a tag
/// failure indistinguishable from corruption.
/// </remarks>
[Fact]
public async Task AGrantForAKeyTheRecipientDoesNotHold_IsRefused()
{
var owner = await EnrolledClientAsync("stale-owner", "sowner@example.com");
await EnrolledClientAsync("stale-member", "smember@example.com");
var team = await CreateTeamAsync(owner, "Stale");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "smember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var response = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants",
new IssueVaultGrantRequest(
entry.UserId,
RecipientKeyFingerprint: new byte[32],
KeyGeneration: 1,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
/// <remarks>
/// The directory has no search. Asserting it rather than trusting the implementation, because a
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
/// way to enumerate an organisation's staff.
/// </remarks>
[Fact]
public async Task TheDirectory_MatchesAnExactAddressAndNothingElse()
{
var client = await EnrolledClientAsync("directory-self", "findme@example.com");
var address = addresses["findme@example.com"];
var exact = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
exact.Count.ShouldBe(1);
// Case-insensitive, because the column is citext and two addresses differing only in case are
// one account. That is a match, not a search.
var cased = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address.ToUpperInvariant())}");
cased.Count.ShouldBe(1);
// The address with its last character removed. A directory that answered this would be a way to
// walk an organisation's staff list out of a server that stores addresses in plaintext.
var prefix = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}");
prefix.ShouldBeEmpty();
}
/// <remarks>
/// The key log has to verify from genesis with the hashes the server publishes, because that is the
/// whole of what a client can check. A chain that only the server could reproduce would make key
/// transparency a claim rather than a mechanism.
/// </remarks>
[Fact]
public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes()
{
var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com");
var page = await ReadAsync<KeyLogPage>(client, "/api/v1/keylog?after=0");
page.Entries.ShouldNotBeEmpty();
var previous = Crypto.KeyLogChain.CreateGenesisPreviousHash();
foreach (var entry in page.Entries)
{
entry.PreviousHash.ShouldBe(previous);
Crypto.KeyLogChain.ComputeEntryHash(
entry.PreviousHash,
entry.UserId,
entry.Generation,
entry.EncryptionPublicKey,
entry.SigningPublicKey,
entry.StatementSignature,
entry.CreatedAt).ShouldBe(entry.Hash);
previous = entry.Hash;
}
// And the head the page reports is the last link, or a client that paged to the end could not
// tell whether it had seen the whole log.
if (!page.HasMore)
{
page.Head.ShouldBe(previous);
}
}
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
{
var unique = $"{subject}-{Guid.CreateVersion7():N}";
var address = email is null ? null : $"{Guid.CreateVersion7():N}-{email}";
using var enrollment = new TestEnrollment(fixture.IdentityProvider, unique, address);
var client = fixture.CreateClientFor(unique, address);
var response = await client.PostContractAsync(EnrollUrl, enrollment.Build());
response.EnsureSuccessStatusCode();
// The address is remembered on the client so a later directory lookup can name it: the tests
// uniquify addresses so that runs against a shared container cannot collide.
if (address is not null)
{
addresses[email!] = address;
}
return client;
}
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
/// <remarks>
/// The slug is generated rather than derived from the name, because a slug is lowercase letters,
/// digits and hyphens and a display name is not — deriving one would make these tests depend on a
/// transformation the product does not perform. It is uniquified because the container is shared
/// across every class in this assembly and the slug is unique deployment-wide.
/// </remarks>
private Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
PostAsync<CreateTeamRequest, TeamSummary>(
client,
TeamsUrl,
new CreateTeamRequest(
Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null));
/// <remarks>
/// The wrapped key and the signature are the right shape and nothing more. The server stores both
/// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
/// crypto library rather than the endpoint.
/// </remarks>
private async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
{
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
client,
$"{TeamsUrl}/{teamId}/vaults",
new CreateTeamVaultRequest(
Guid.CreateVersion7(),
"Team vault",
WrappedVaultKey: new byte[110],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
return vault.VaultId;
}
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
{
var address = addresses.GetValueOrDefault(email, email);
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
return found.ShouldHaveSingleItem();
}
private static async Task AddMemberAsync(
HttpClient client,
Guid teamId,
Guid userId,
TeamMemberRole role)
{
var response = await client.PostContractAsync(
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
response.EnsureSuccessStatusCode();
}
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
HttpClient client,
string url,
TRequest body)
{
var response = await client.PostContractAsync(url, body);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<TResponse>())!;
}
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
{
var response = await client.GetAsync(
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<T>())!;
}
}
@@ -0,0 +1,76 @@
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Api.Tests;
/// <summary>
/// The team enums on the wire and the ones in the domain have to agree, and nothing but this makes them.
/// </summary>
/// <remarks>
/// <para>
/// The same hazard <c>EntityTypeAlignmentTests</c> exists for, one feature along and with a worse failure.
/// <c>TeamService</c> maps <see cref="TeamMemberRole"/> to <see cref="TeamRole"/> member by member, so a
/// renumbering on one side does not fail to compile — it silently changes what a role means. Someone
/// added as a viewer would come back as an admin, or the reverse, on the next deployment.
/// </para>
/// <para>
/// Mapped by <em>name</em> in the service and asserted by <em>value</em> here, which is the pairing that
/// catches the mistake: the service would go on compiling after a renumbering, and this would not go on
/// passing.
/// </para>
/// </remarks>
public sealed class TeamEnumAlignmentTests
{
[Fact]
public void EveryWireRole_HasADomainRoleWithTheSameValue()
{
((int)TeamMemberRole.Unspecified).ShouldBe((int)TeamRole.Unspecified);
((int)TeamMemberRole.Viewer).ShouldBe((int)TeamRole.Viewer);
((int)TeamMemberRole.Member).ShouldBe((int)TeamRole.Member);
((int)TeamMemberRole.Admin).ShouldBe((int)TeamRole.Admin);
((int)TeamMemberRole.Owner).ShouldBe((int)TeamRole.Owner);
}
[Fact]
public void TheTwoRoleEnums_HaveTheSameNumberOfMembers() =>
Enum.GetValues<TeamMemberRole>().Length.ShouldBe(Enum.GetValues<TeamRole>().Length);
[Fact]
public void EveryWireMembershipStatus_HasADomainStatusWithTheSameValue()
{
((int)TeamMemberStatus.Unspecified).ShouldBe((int)MembershipStatus.Unspecified);
((int)TeamMemberStatus.Invited).ShouldBe((int)MembershipStatus.Invited);
((int)TeamMemberStatus.Active).ShouldBe((int)MembershipStatus.Active);
((int)TeamMemberStatus.Revoked).ShouldBe((int)MembershipStatus.Revoked);
}
[Fact]
public void EveryWireGrantState_HasADomainStateWithTheSameValue()
{
((int)VaultGrantState.Unspecified).ShouldBe((int)GrantState.Unspecified);
((int)VaultGrantState.Active).ShouldBe((int)GrantState.Active);
((int)VaultGrantState.AwaitingRewrap).ShouldBe((int)GrantState.AwaitingRewrap);
((int)VaultGrantState.Revoked).ShouldBe((int)GrantState.Revoked);
}
/// <remarks>
/// <para>
/// <c>VaultSummary.Permissions</c> is an opaque int on the wire, on purpose — the flags live in
/// <c>DodoSSH.Domain</c> and no client project references that assembly. So the client repeats the one
/// bit it needs as a literal in <c>StoredVault.CanWrite</c>, and this pins the value it copied.
/// </para>
/// <para>
/// Asserted here rather than against <c>StoredVault</c> itself, which would mean a server test project
/// taking a reference on a client assembly to check a constant. The consequence of drift is worth the
/// literal either way: a Save button offered to somebody who is only a viewer of a team vault, ending
/// in a 403 they can do nothing about — or no Save button for somebody who may write.
/// </para>
/// </remarks>
[Fact]
public void TheWriteFlag_IsTheBitTheClientCopied()
{
((int)PermissionFlags.Read).ShouldBe(1 << 0);
((int)PermissionFlags.Write).ShouldBe(1 << 1);
}
}
@@ -43,18 +43,31 @@ internal static class LayoutHarness
internal const double NavRailWidth = 54;
/// <summary>
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
/// </summary>
/// <remarks>
/// Both are fixed heights declared in their own markup — 38 and 24 — rather than shapes that grow with
/// their contents, which is what makes stating them here honest. Two tests hold the two controls to
/// those numbers, so the budget below cannot drift away from what the window actually leaves.
/// All three are fixed heights declared in their own markup — 38, 34 and 24 — rather than shapes that
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
/// controls to those numbers, so the budget below cannot drift away from what the window actually
/// leaves.
/// </remarks>
internal const double TitleBarHeight = 38;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
/// </summary>
/// <remarks>
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
/// and went would move every screen up and down by 34 pixels each time the last tab closed — so this is
/// a fixed cost rather than a conditional one, and the budget can be a constant.
/// </remarks>
internal const double TerminalTabsHeight = 34;
/// <summary>
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
/// </summary>
@@ -67,10 +80,24 @@ internal static class LayoutHarness
internal const double CardContentWidth = 520 - (2 * 24);
/// <inheritdoc cref="CardContentWidth" />
internal static double CardContentHeight => ScreenHeight - (2 * 24);
/// <remarks>
/// Measured against <see cref="ContentHeight"/> and not against <see cref="ScreenHeight"/>, which is a
/// distinction the tab strip introduced and which is worth stating: a setup card is shown while the
/// vault is <em>not</em> open, and the strip lives inside the unlocked half of the window. So the card
/// gets the whole area between the titlebar and the status bar, and taking the strip off its budget
/// would have this harness fail a card that fits.
/// </remarks>
internal static double CardContentHeight => ContentHeight - (2 * 24);
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <summary>The height a screen actually gets at the window's minimum.</summary>
internal static double ScreenHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <remarks>
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
/// </remarks>
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
@@ -4,10 +4,10 @@ using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
@@ -4,10 +4,12 @@ using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
@@ -179,6 +181,24 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Headings are rows in the same list as the hosts, drawn from a different template, and they are the
/// widest thing in a 268-pixel column: a name, a chevron and a count on one line. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithGroupHeadingsInTheList()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
@@ -302,12 +322,108 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
vault.Status.ShouldContain(
"not in this vault any more",
"not in this keychain any more",
Case.Insensitive,
"the double-click has to reach the connect command");
});
}
// ---- The hosts screen ----
//
// Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal
// moved out from under it, and nothing in that window can be laid out here — so the connect banner, the
// two host key prompts and the conflict log had never been through this harness at all. They are also
// the four worst candidates for that: each appears only in a state somebody has to reproduce by hand.
[Fact]
public async Task TheHostsScreenFitsWithNothingToAnnounce()
{
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("the ordinary shape"));
}
[Fact]
public async Task TheHostsScreenFitsWithAHostSelected()
{
vault.SelectedHost = vault.Hosts[0];
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the overview showing a host"));
}
[Fact]
public async Task TheHostsScreenFitsWhileAHostKeyIsBeingApproved()
{
vault.PendingHostKey = new HostKeyPresentation(
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the unknown-key prompt up"));
}
[Fact]
public async Task TheHostsScreenFitsWhileAHostKeyIsRefused()
{
vault.HostKeyMismatch =
"db.internal:22 presented ssh-ed25519 SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0, "
+ "and this keychain has SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI pinned for it.";
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
}
/// <remarks>
/// Twenty, because one is not the case that broke. The log sits on an <c>Auto</c> row above the overview,
/// and an <c>ItemsControl</c> with no ceiling grows for as long as it has rows — so a pass that merged a
/// vault's worth of items pushed everything below it off the bottom of a screen with nothing to scroll.
/// It survived as long as it did because this markup was inside the window, where no test could reach it;
/// finding it is what the extraction was for. The fix is the <c>ScrollViewer</c> and <c>MaxHeight</c> in
/// <c>HostsScreen.axaml</c>, and this is what holds them there.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithAConflictLogTooLongToShow()
{
for (var i = 0; i < 20; i++)
{
vault.Conflicts.Add(new ConflictRowViewModel(new ConflictNotice(
Guid.CreateVersion7(),
Guid.CreateVersion7(),
ConflictKind.FieldOverridden,
$"'host-{i}' was changed on two machines, and the other machine's value was kept.",
[],
TimeProvider.System.GetUtcNow())));
}
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with twenty merged conflicts to report"));
}
/// <remarks>
/// The group panel is a row of its own at the foot of this screen, so it competes with the overview above
/// it for the same column — and it grows sideways as groups are added, which is the direction a
/// fixed-width column has least of. Six, because that is more than anybody's first three and enough to
/// need the horizontal scroller rather than to overflow silently.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithMoreGroupsThanTheRowHasRoomFor()
{
await SeedGroupsAsync(6);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six groups along the bottom"));
}
/// <remarks>
/// The question replaces the buttons rather than stacking under them — the same rule the sidebar's own
/// deletion follows — and it is the taller of the two, because it says how many hosts are about to move.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
{
await SeedGroupsAsync(3);
vault.SelectedGroup = vault.Groups[0];
vault.DeleteGroupCommand.Execute(null);
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
}
// ---- The vault screen ----
[Fact]
@@ -315,7 +431,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
foreach (var section in new[]
{
VaultSection.All, VaultSection.Keys, VaultSection.Credentials, VaultSection.KnownHosts,
VaultSection.All, VaultSection.Keys, VaultSection.Credentials,
})
{
vault.Section = section;
@@ -353,21 +469,197 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The detail pane with something selected, which is what the design's right-hand column is really about
/// — and the pin is the one carrying a full fingerprint on a wrapped monospace line.
/// The generate form, in the 244-pixel detail pane — two algorithm buttons side by side plus two
/// paragraphs of explanation, in the narrowest column in the application. The paragraphs are the risk:
/// they are what says the file has no passphrase, and a sentence pushed off the bottom is a limitation
/// nobody was told about.
/// </remarks>
[Fact]
public async Task TheVaultScreenFitsWithAPinSelected()
public async Task TheVaultScreenFitsWithTheGenerateFormOpen()
{
vault.Section = VaultSection.KnownHosts;
vault.VaultItems.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
vault.SelectedVaultItem = vault.VaultItems[0];
vault.SelectedItemIsPin.ShouldBeTrue();
vault.NewGeneratedKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeTrue();
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Both drop highlights forced on at once, which is a state the screen never actually reaches — the
/// point is that an overlay covering a whole pane does not change the layout of anything beneath it.
/// It cannot check the thing most likely to be wrong, which is <c>IsHitTestVisible="False"</c>: an
/// overlay that hit-tests lays out identically and swallows the events that would clear it. That one is
/// in docs/manual-checks.md.
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithTheDropHighlightsShowing()
{
transfers.IsLocalDropTarget = true;
transfers.IsRemoteDropRefused = true;
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
}
// ---- The import screen ----
[Fact]
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
{
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
}
/// <remarks>
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
/// understands and one they think is broken.
/// </remarks>
[Fact]
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
{
await MeasureImportAsync(
faults => faults.ShouldBeEmpty("with a scanned list"),
await ScannedImportAsync());
}
// ---- The host keys screen ----
[Fact]
public async Task TheHostKeysScreenFitsWithNothingApprovedYet()
{
foreach (var pin in vault.KnownHostPins.ToList())
{
await knownHosts.ForgetAsync(pin.Host, pin.Port, Token);
}
await vault.LoadAsync(Token);
vault.KnownHostPins.ShouldBeEmpty();
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("the empty state"));
}
/// <remarks>
/// The shape the column widths were chosen for. A fingerprint is never trimmed — comparing a shortened
/// one against a published one is not something anybody can do — so this table has one column that
/// refuses to give ground, and this is what says the rest still fits beside it.
/// </remarks>
[Fact]
public async Task TheHostKeysScreenFitsWithPinsAndOneSelected()
{
var pins = new KnownHostsViewModel(vault);
pins.VisiblePins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
pins.Selected = pins.VisiblePins[0];
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with a pin selected"), pins);
}
[Fact]
public async Task TheHostKeysScreenFitsWhenTheFilterMatchesNothing()
{
var pins = new KnownHostsViewModel(vault) { Filter = "no such fingerprint" };
pins.VisiblePins.ShouldBeEmpty();
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins);
}
// ---- The logs screen ----
[Fact]
public async Task TheLogsScreenFitsWithNeitherLogWrittenTo()
{
await MeasureLogsAsync(faults => faults.ShouldBeEmpty("the empty state"), LogSection.Connections);
}
/// <remarks>
/// Six columns in one row, and the two widest — an address and a device name — are both variable. A
/// connection still open is measured alongside the finished ones because its row carries the longest
/// value the LASTED column ever holds: the words "still open" rather than a duration.
/// </remarks>
[Fact]
public async Task TheConnectionLogFitsWithALiveRowAndAFinishedOne()
{
var logs = await SeedLogsAsync();
logs.Connections.ShouldNotBeEmpty();
logs.Connections.Any(row => row.IsLive).ShouldBeTrue("the live row is the wide one");
await MeasureLogsAsync(
faults => faults.ShouldBeEmpty("with a live connection above a finished one"),
LogSection.Connections,
logs);
}
/// <remarks>
/// The FIELDS column is the one that grows: it is a list of names, and a host has eleven of them.
/// Measured with an edit that touched several, because one field name fits anywhere.
/// </remarks>
[Fact]
public async Task TheActivityLogFitsWithAnEditThatTouchedSeveralFields()
{
var logs = await SeedLogsAsync();
logs.Section = LogSection.Activity;
logs.Activity.ShouldNotBeEmpty();
await MeasureLogsAsync(
faults => faults.ShouldBeEmpty("with the keychain log showing"), LogSection.Activity, logs);
}
// ---- The snippets screen ----
[Fact]
public async Task TheSnippetsScreenFitsWithNothingSavedYet()
{
vault.Snippets.ShouldBeEmpty("the seed makes none, which is what a new keychain looks like");
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("the empty state"));
}
/// <remarks>
/// The detail pane's longest shape: a multi-line command in a box, its notes, two buttons and the
/// paragraph saying what a terminal will do with it — in a 300-pixel column. Measured with a snippet
/// that runs, because that is the one with the extra button.
/// </remarks>
[Fact]
public async Task TheSnippetsScreenFitsWithAMultiLineSnippetSelected()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen(new InsertTarget(1, "prod-db"));
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with a running snippet selected"), snippets);
}
/// <remarks>
/// The editor, which is the tallest thing on this screen: a name, a 140-pixel command box, notes, the
/// checkbox and the paragraph explaining what leaving it off buys.
/// </remarks>
[Fact]
public async Task TheSnippetsScreenFitsWithItsEditorOpen()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
snippets.Selected = snippets.Visible[0];
snippets.EditCommand.Execute(null);
snippets.IsEditing.ShouldBeTrue();
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
}
[Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
snippets.Filter = "no such command";
snippets.Visible.ShouldBeEmpty();
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), snippets);
}
/// <remarks>
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
@@ -574,12 +866,15 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// Five destinations in a 54-pixel column. The rail runs vertically, so what runs out here is height
/// rather than width — at the window's minimum the five entries have to leave room for each other, which
/// is the same failure the old four-button selector was one label away from.
/// Eight destinations in a 54-pixel column. The rail runs vertically, so what runs out here is height
/// rather than width — at the window's minimum the entries have to leave room for each other, which is
/// the same failure the old four-button selector was one label away from. It got tighter when the host
/// keys left the keychain screen and became a destination of their own, and tighter again with snippets
/// and then the logs — which is why the count is asserted rather than left to the fit check: an entry
/// silently dropping off the bottom would still pass every other assertion here.
/// </remarks>
[Fact]
public async Task TheNavRailHoldsFiveDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsEightDestinationsAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
@@ -592,7 +887,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(5, "one per screen the rail reaches");
buttons.Count.ShouldBe(8, "one per screen the rail reaches");
foreach (var button in buttons)
{
@@ -640,7 +935,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
+ "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";
await MeasureCardAsync(new UnlockCard());
await MeasureCardAsync(static () => new UnlockCard());
}
[Fact]
@@ -698,14 +993,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
shell.LiveSessionCount = 1;
shell.Transfers.IsConnected = true;
await MeasureCardAsync(new SignOutCard());
await MeasureCardAsync(static () => new SignOutCard());
}
/// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
private Task MeasureCardAsync(Control card) =>
/// <remarks>
/// The card is <em>built</em> inside the dispatched call rather than passed in already constructed, and
/// that is not style. Avalonia binds <c>Dispatcher.UIThread</c> to whichever thread first asks for it, so
/// a control constructed on the test thread before any other test has dispatched makes that thread the
/// UI thread — and every later property set from the harness's own thread then throws. It depends on the
/// order the tests happen to run in, which is why it survived until a phase that added new ones.
/// </remarks>
private Task MeasureCardAsync(Func<Control> build) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var card = build();
card.DataContext = shell;
var window = LayoutHarness.HostAtMinimumSize(
@@ -748,6 +1051,250 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
/// <remarks>
/// The shell is the data context, not the vault — the sidebar is handed the vault from inside the
/// screen's own markup. <see cref="MainWindowViewModel.Vault"/> is assigned rather than reached through
/// an unlock, which would be a second enrollment for no extra rectangle.
/// </remarks>
private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Vault = vault;
shell.State = ShellState.Unlocked;
var screen = new HostsScreen { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,
ImportViewModel? import = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new ImportScreen
{
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// An import view model that has scanned a real file, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through a temporary directory rather than by populating the rows directly, because the shape being
/// measured is what the parser produces — an entry with two warnings under it is taller than one
/// without, and inventing the rows would measure a layout nothing generates.
/// </remarks>
private async Task<ImportViewModel> ScannedImportAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-import-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(directory);
try
{
await File.WriteAllTextAsync(
Path.Combine(directory, "config"),
"""
Host *
ServerAliveInterval 30
Host prod-db
HostName database.production.internal
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host bastion-eu-west-1
HostName bastion.eu-west-1.example.com
User ops
ProxyCommand nc %h %p
Compression yes
compression no
Match host anything
User root
""");
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
// table — which is the other test.
await import.ScanCommand.ExecuteAsync(null);
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
return import;
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
private Task MeasurePinsAsync(
Action<IReadOnlyList<string>> assert,
KnownHostsViewModel? pins = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new KnownHostsScreen { DataContext = pins ?? new KnownHostsViewModel(vault) };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the logs screen out at the size it gets beside the nav rail.</summary>
private Task MeasureLogsAsync(
Action<IReadOnlyList<string>> assert,
LogSection section,
LogsViewModel? logs = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var model = logs ?? NewLogsScreen();
model.Section = section;
var screen = new LogsScreen { DataContext = model };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
private LogsViewModel NewLogsScreen(params LiveConnection[] live) =>
new(session, () => live);
/// <summary>
/// Writes one of each kind of entry and reads them back.
/// </summary>
/// <remarks>
/// Through the repositories the recorders write to, rather than through the recorders themselves: those
/// write on a background task on purpose, and a layout suite that waited on one would be measuring
/// rectangles behind a race.
/// </remarks>
private async Task<LogsViewModel> SeedLogsAsync()
{
await session.ConnectionLog.CreateAsync(
session.ActiveVaultId,
new ConnectionLogSecret
{
HostLabel = "customer-a-production-database",
Address = "deployment-account@db-01.customer-a.internal:22022",
StartedAt = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
Duration = TimeSpan.FromMinutes(74),
Outcome = ConnectionOutcome.Refused,
DeviceName = "jaap-jan-workstation",
},
Token);
await session.ActivityLog.CreateAsync(
session.ActiveVaultId,
new ActivityLogSecret
{
ItemKind = "Host",
ItemId = Guid.CreateVersion7(),
ItemLabel = "customer-a-production-database",
Operation = ActivityOperation.Updated,
ChangedFields = "Hostname, Port, Username, Options, Group",
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
DeviceName = "jaap-jan-workstation",
},
Token);
var logs = NewLogsScreen(new LiveConnection(
"customer-a-production-database",
"deployment-account@db-01.customer-a.internal:22022",
new DateTimeOffset(2026, 7, 31, 8, 0, 0, TimeSpan.Zero),
"jaap-jan-workstation"));
await logs.ReloadAsync(Token);
return logs;
}
/// <summary>Lays the snippets screen out at the size it gets beside the nav rail.</summary>
/// <remarks>
/// The insert function throws. Nothing measured here presses a button, and a substitute that returned a
/// plausible answer would make it possible to write a layout test that quietly exercised the transport.
/// </remarks>
private Task MeasureSnippetsAsync(
Action<IReadOnlyList<string>> assert,
SnippetsViewModel? snippets = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new SnippetsScreen { DataContext = snippets ?? NewSnippetsScreen() };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
new(
vault,
() => target ?? InsertTarget.None,
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -868,4 +1415,69 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await vault.LoadAsync(Token);
}
/// <summary>
/// Adds snippets, including the two shapes that decide this screen's height.
/// </summary>
/// <remarks>
/// Not part of <see cref="SeedAsync"/>, so the empty state stays measurable — and because most keychains
/// have none, which is the shape somebody sees the first time they open the screen.
/// </remarks>
private async Task SeedSnippetsAsync()
{
await vault.SaveSnippetAsync(
null,
new SnippetSecret
{
Label = "tail the application log",
Command = "sudo journalctl -u dodossh-api -f --since '10 minutes ago'",
Notes = "Ctrl+C to stop.",
},
Token);
await vault.SaveSnippetAsync(
null,
new SnippetSecret
{
Label = "restart the api",
Command = "sudo systemctl daemon-reload\nsudo systemctl restart dodossh-api\nsystemctl status dodossh-api --no-pager",
Notes = "Check the on-call rota before running this in production.",
RunsOnInsert = true,
},
Token);
vault.Snippets.Count.ShouldBe(2);
}
/// <summary>
/// Adds groups and files the seeded hosts across them.
/// </summary>
/// <remarks>
/// Not part of <see cref="SeedAsync"/>, on purpose. A vault with no groups is what a new one is and what
/// most of them stay, and it is the shape in which the sidebar draws no headings at all — so it has to
/// remain the one every other test here measures.
/// </remarks>
private async Task SeedGroupsAsync(int count)
{
for (var i = 0; i < count; i++)
{
vault.GroupEditorLabel = $"customer-{i}-production";
await vault.SaveGroupCommand.ExecuteAsync(null);
}
vault.Groups.Count.ShouldBe(count);
// Filed through the host editor, which is the only way a user can do it, so this also exercises the
// picker the sidebar's headings are built out of.
for (var i = 0; i < vault.Hosts.Count; i++)
{
vault.SelectedHost = vault.Hosts[i];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedGroup = vault.EditorGroupChoices
.First(choice => choice.EntityId == vault.Groups[i % count].EntityId);
await vault.SaveHostCommand.ExecuteAsync(null);
}
}
}
@@ -0,0 +1,292 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the tab strip answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one
/// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a
/// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and
/// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a
/// binding.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the reason the palette's suite is one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. No vault and no session — the strip
/// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so
/// they can be put there directly. Closing one asks the workspace to end a session it has never heard of,
/// which the workspace answers by returning: that is the same path a real close takes, minus a shell.
/// </para>
/// </remarks>
public sealed class TerminalTabsTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"tabs-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
// The only state the strip is ever interactive in. Assigned rather than reached through an
// enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes
/// one, and the strip answered nothing but a left click before.
/// </remarks>
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The other half of the rule, and the reason the handler is on the tab's own template root rather than
/// on the strip: a middle click on the chrome between the last tab and the edge of the window must not
/// close anything. Wiring it on the strip and testing what was underneath the pointer would have been
/// the same feature with a way to get it wrong.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
// Well right of two short tabs and the button after them, and inside the strip's own height.
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
/// <remarks>
/// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One
/// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The one assumption the nested-button template makes, stated as a test. Avalonia's
/// <c>Button.OnPointerPressed</c> takes the capture and marks a left press handled, so the cross does
/// not also reach the tab underneath it — which would select a tab on its way out and leave the
/// terminal switching to something that is about to disappear.
/// </remarks>
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing.
/// See the note in <c>TerminalTabs.axaml</c> for why a flyout over the terminal's rectangle is not a
/// claim this project is willing to make without a screenshot.
/// </remarks>
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <remarks>
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
/// and this is what stops the budget drifting from the markup. See
/// <see cref="LayoutHarness.TerminalTabsHeight"/>.
/// </remarks>
[Fact]
public async Task TheStripIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new TerminalTabs { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// What it asks for, not what this host window gave it. Hosting it at 34 and then
// asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is
// exactly the regression the budget needs catching.
strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
// ---- Helpers ----
/// <summary>Two open tabs, laid out in a window the width the application's is.</summary>
private Task OnTheStripAsync(Action<TerminalTabs, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new TerminalTabs { DataContext = shell };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Found by the class the style system already keys on, rather than by position in the visual tree: the
/// template puts the cross inside the tab, so both buttons carry the same data context and only the
/// classes tell them apart.
/// </remarks>
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("tab"));
/// <inheritdoc cref="TabButton" />
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
/// <inheritdoc cref="TabButton" />
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
}
@@ -485,6 +485,8 @@
"Avalonia.Fonts.Inter": "[12.1.1, )",
"Avalonia.Themes.Fluent": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Import": "[1.0.0, )",
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Shell": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
@@ -498,6 +500,21 @@
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.import": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Domain": "[1.0.0, )"
}
},
"dodossh.client.objectstore": {
"type": "Project",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, )",
"AWSSDK.S3": "[4.0.101.6, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.session": {
"type": "Project",
"dependencies": {
@@ -506,7 +523,8 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
"DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.shell": {
@@ -514,6 +532,8 @@
"dependencies": {
"Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Import": "[1.0.0, )",
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
@@ -523,6 +543,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -618,6 +639,21 @@
"Avalonia": "12.1.1"
}
},
"AWSSDK.Core": {
"type": "CentralTransitive",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "CentralTransitive",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
@@ -0,0 +1,366 @@
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The team, directory and grant half of the fake server.
/// </summary>
/// <remarks>
/// <para>
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
/// make every sharing test pass against a check that was never exercised. It also means a test can break
/// the chain deliberately and watch the client refuse.
/// </para>
/// <para>
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
/// can be created, a member added, and a vault key wrapped and recorded.
/// </para>
/// </remarks>
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
{
private readonly List<TeamSummary> teams = [];
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
/// <inheritdoc />
public ITeamApi Teams => this;
/// <inheritdoc />
public IDirectoryApi Directory => this;
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
/// <summary>
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
/// </summary>
/// <remarks>
/// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
/// wrong can only ever confirm the happy path.
/// </remarks>
internal bool CorruptKeyLog { get; set; }
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
/// <returns>Their user id.</returns>
internal Guid AddAccount(string email, string displayName)
{
var userId = Guid.CreateVersion7();
// Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
// an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);
var sequence = AppendKeyLog(
userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);
directory.Add(new DirectoryEntry(
userId,
email,
displayName,
bundle.EncryptionPublicKey,
bundle.SigningPublicKey,
DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
KeyGeneration: 1,
KeyLogSequence: sequence));
return userId;
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken)
{
var team = new TeamSummary(
request.TeamId,
request.Name,
request.Slug,
request.Description,
TeamMemberRole.Owner,
MemberCount: 1,
VaultCount: 0,
DateTimeOffset.UnixEpoch);
teams.Add(team);
members[team.TeamId] =
[
new TeamMemberSummary(
UserId,
"alice@example.com",
"Alice Example",
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch),
];
return Task.FromResult(team);
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
members.TryGetValue(teamId, out var list) ? [.. list] : []);
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken)
{
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
?? throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"No such account on this server.");
var member = new TeamMemberSummary(
entry.UserId,
entry.Email,
entry.DisplayName,
request.Role,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch);
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
Recount(teamId);
return Task.FromResult(member);
}
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var index = list.FindIndex(member => member.UserId == userId);
if (index < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"That account is not an active member of this team.");
}
list[index] = list[index] with { Role = request.Role };
return Task.FromResult(list[index]);
}
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var removed = list.RemoveAll(member => member.UserId == userId) > 0;
// Every grant they held from this team goes with them, as the real service revokes them in the
// same transaction. A fake that removed the membership and left the grants would let a test
// "prove" a revocation that had not happened.
foreach (var vaultId in teamVaults.Values
.Where(vault => vault.TeamId == teamId)
.Select(vault => vault.VaultId))
{
grants.Remove((vaultId, userId));
}
Recount(teamId);
return Task.FromResult(removed);
}
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken)
{
var vault = new VaultSummary(
request.VaultId,
request.Name,
IsPersonal: false,
TeamId: teamId,
KeyGeneration: 1,
Permissions: 31,
request.WrappedVaultKey,
RekeyRequired: false);
teamVaults[vault.VaultId] = vault;
Recount(teamId);
return Task.FromResult(vault);
}
/// <inheritdoc />
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<DirectoryEntry>>(
[
.. directory.Where(entry =>
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
]);
/// <inheritdoc />
public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
Task.FromResult(directory.Find(entry => entry.UserId == userId));
/// <inheritdoc />
public Task<KeyLogPage> ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();
if (CorruptKeyLog && page.Count > 0)
{
// One byte, in the field the chain is built from. Enough to break the link and nothing else,
// which is what a tampered log would look like.
var last = page[^1];
page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
}
var head = keyLog.Count == 0
? KeyLogChain.CreateGenesisPreviousHash()
: keyLog[^1].Hash;
return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
}
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 1,
RekeyRequired: false,
Grants:
[
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
new VaultGrantSummary(
entry.Key.UserId,
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
null,
KeyGeneration: 1,
VaultGrantState.Active,
UserId,
DateTimeOffset.UnixEpoch,
null)),
]));
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
grants[(vaultId, request.RecipientUserId)] = request;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
Task.FromResult(grants.Remove((vaultId, userId)));
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
{
if (directory.Exists(entry => entry.UserId == UserId))
{
return;
}
var sequence = AppendKeyLog(
UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);
directory.Add(new DirectoryEntry(
UserId,
"alice@example.com",
"Alice Example",
statement.EncryptionPublicKey,
statement.SigningPublicKey,
DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
statement.KeyGeneration,
sequence));
}
/// <summary>Appends a key log entry, chained as the real log chains it.</summary>
private long AppendKeyLog(
Guid userId,
byte[] encryptionPublicKey,
byte[] signingPublicKey,
byte[] statementSignature)
{
var previous = keyLog.Count == 0
? KeyLogChain.CreateGenesisPreviousHash()
: keyLog[^1].Hash;
var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
var sequence = keyLog.Count + 1;
var hash = KeyLogChain.ComputeEntryHash(
previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);
keyLog.Add(new KeyLogRecord(
sequence,
userId,
Generation: 1,
encryptionPublicKey,
signingPublicKey,
statementSignature,
previous,
hash,
createdAt));
return sequence;
}
private void Recount(Guid teamId)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
return;
}
teams[index] = teams[index] with
{
MemberCount = members.GetValueOrDefault(teamId, []).Count,
VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
};
}
}
@@ -17,7 +17,7 @@ namespace DodoSSH.Client.App.Tests;
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
/// version checks.
/// </remarks>
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
{
private readonly List<SyncChange> log = [];
@@ -41,7 +41,24 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
internal bool IsEnrolled => statement is not null;
internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete);
/// <summary>
/// How many of the <em>user's</em> items are live on this server.
/// </summary>
/// <remarks>
/// Log entries are excluded, and every assertion that uses this was written before they existed and
/// means exactly what it says: "the host reached the server". Counting the connection and activity
/// entries alongside them would make a number about somebody's keychain depend on how many times they
/// had connected — which is what <see cref="LogRowCount"/> is for.
/// </remarks>
internal int LiveRowCount => rows.Values.Count(row =>
row.Operation != SyncOperation.Delete && !IsLog(row.EntityType));
/// <summary>How many log entries are live on this server, of either kind.</summary>
internal int LogRowCount => rows.Values.Count(row =>
row.Operation != SyncOperation.Delete && IsLog(row.EntityType));
private static bool IsLog(SyncEntityType type) =>
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
@@ -114,7 +131,11 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
KeyGeneration: statement?.KeyGeneration,
WrappedPrivateKey: wrappedPrivateKey,
KdfParameters: kdfParameters,
Vaults: personalVault is null ? [] : [personalVault]));
// Team vaults alongside the personal one, in the order the real /me returns them: this is
// where a vault somebody shared arrives, and a fake that only ever reported the personal one
// would make a refresh that admits a new vault untestable.
Vaults: personalVault is null ? [] : [personalVault, .. teamVaults.Values]));
/// <inheritdoc />
public Task<EnrollmentResponse> EnrollAsync(
@@ -127,6 +148,10 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
wrappedPrivateKey = request.WrappedPrivateKey;
kdfParameters = request.KdfParameters;
// The enrolling account joins the directory and the key log, as it does on the real server. Both
// are what a later share reads: this client verifies its own entry as part of verifying anyone's.
RegisterSelf(request.Statement, request.StatementSignature);
personalVault = new VaultSummary(
request.PersonalVault.VaultId,
request.PersonalVault.Name,
+869 -11
View File
@@ -1,10 +1,10 @@
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
@@ -494,6 +494,250 @@ public sealed class ShellFlowTests : IAsyncLifetime
requests.ShouldBe(0);
}
// ---- Which surface is showing ----
//
// The tab strip is visible from every screen, so a terminal and a page are two things the window can be
// showing rather than one screen among five. These fix that state machine. None of them can see the
// WebView itself — headless Avalonia has no native window — but every transition below is decided here,
// in ordinary objects, which is why they are worth having.
/// <remarks>
/// The point of the whole rework, stated as one assertion: a terminal opened from somewhere other than
/// the hosts screen shows, and the screen underneath it does not move. Moving it would make opening a
/// terminal a way to lose your place in a transfer that is still running.
/// </remarks>
[Fact]
public async Task OpeningATerminalFromAnotherScreen_ShowsItAndLeavesTheScreenWhereItWas()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
await vault.ConnectCommand.ExecuteAsync(null);
shell.Surface.ShouldBe(ShellSurface.Terminal);
shell.IsTerminalShowing.ShouldBeTrue();
shell.IsShowingPages.ShouldBeFalse();
shell.Screen.ShouldBe(ShellScreen.Transfers, "the page underneath is what closing the tab returns to");
}
[Fact]
public async Task ANavRailClick_HidesTheTerminalAndKeepsTheTab()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
shell.IsTerminalShowing.ShouldBeFalse();
shell.IsShowingPages.ShouldBeTrue();
shell.IsVaultShowing.ShouldBeTrue();
// The session is untouched. Navigating away from a terminal is not a way to end one; only closing
// its tab is.
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsLive.ShouldBeTrue();
shell.SelectedTab.ShouldBe(tab);
}
[Fact]
public async Task ClickingATab_BringsTheTerminalBackFromAPage()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.IsTerminalShowing.ShouldBeTrue();
shell.Screen.ShouldBe(ShellScreen.Preferences);
}
/// <remarks>
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
/// transition that moves the surface back on its own.
/// </remarks>
[Fact]
public async Task ClosingTheLastTab_ReturnsToThePageThatWasShowing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
await vault.ConnectCommand.ExecuteAsync(null);
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
shell.Tabs.ShouldBeEmpty();
shell.SelectedTab.ShouldBeNull();
shell.IsTerminalShowing.ShouldBeFalse();
shell.IsTransfersShowing.ShouldBeTrue("the page that was showing when the terminal opened");
}
[Fact]
public async Task ClosingOneOfTwoTabs_KeepsTheTerminalShowing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
await vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.Count.ShouldBe(2);
// The selected one, which is the second. The neighbour takes its place and the terminal stays.
await shell.CloseTabCommand.ExecuteAsync(shell.SelectedTab!);
shell.SelectedTab.ShouldBe(shell.Tabs.ShouldHaveSingleItem());
shell.IsTerminalShowing.ShouldBeTrue();
}
[Fact]
public async Task ClosingATabThatIsNotSelected_ChangesNothingAboutTheSurface()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
var first = shell.Tabs[0];
await vault.ConnectCommand.ExecuteAsync(null);
var second = shell.Tabs[1];
await shell.CloseTabCommand.ExecuteAsync(first);
shell.SelectedTab.ShouldBe(second);
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the
/// terminal share a rectangle, and the card is the one that has to win.
/// </remarks>
[Fact]
public async Task Locking_HidesTheTerminalWhateverTheSurfaceWas()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
shell.IsTerminalShowing.ShouldBeTrue();
await shell.LockCommand.ExecuteAsync(null);
shell.IsTerminalShowing.ShouldBeFalse();
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue("locking does not end a session");
}
[Fact]
public async Task AnUnlock_LandsOnAPageEvenWithATabStillOpen()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
await shell.LockCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
shell.IsHostsShowing.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse();
}
[Fact]
public async Task ThePalette_HidesTheTerminalAndClosingItBringsItBack()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
shell.ToggleSearchCommand.Execute(null);
shell.IsSearching.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse("the palette draws over the terminal's rectangle");
shell.CloseSearchCommand.Execute(null);
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
/// carries that mark, in the strip.
/// </remarks>
[Fact]
public async Task TheNavRailLightsExactlyOneEntryOnAPage_AndNoneOnATerminal()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
LitEntries().ShouldBe(1);
await vault.ConnectCommand.ExecuteAsync(null);
LitEntries().ShouldBe(0);
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
LitEntries().ShouldBe(1);
shell.IsVaultShowing.ShouldBeTrue();
int LitEntries() => new[]
{
shell.IsHostsShowing,
shell.IsTransfersShowing,
shell.IsVaultShowing,
shell.IsTeamShowing,
shell.IsPreferencesShowing,
}.Count(lit => lit);
}
/// <remarks>
/// The palette can be opened from any screen, and an unknown host key is answered by a prompt drawn on
/// the hosts screen. Without this the connection would block on a question sitting behind whatever screen
/// the user happened to be on.
/// </remarks>
[Fact]
public async Task ConnectingFromThePalette_LandsOnTheHostsPageBeforeItCanBeRefused()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
shell.ToggleSearchCommand.Execute(null);
shell.SelectedSearchResult = shell.SearchResults[0];
await shell.ConnectToSearchResultCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue();
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn on the hosts screen");
shell.IsTerminalShowing.ShouldBeFalse();
}
[Fact]
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
{
@@ -1609,7 +1853,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
vault.Status.ShouldContain("not in this vault");
vault.Status.ShouldContain("not in this keychain");
}
[Fact]
@@ -1844,7 +2088,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your vault");
vault.SelectedHostAuthenticationNote.ShouldContain("stored in your keychain");
}
// ---- Authenticating with a stored credential ----
@@ -1952,7 +2196,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
vault.Status.ShouldContain("credential that is not in this vault");
vault.Status.ShouldContain("credential that is not in this keychain");
}
[Fact]
@@ -2169,18 +2413,235 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.KnownHostPins.ShouldHaveSingleItem();
}
/// <remarks>
/// Pins used to be a category on the keychain screen. They are a destination of their own now, and this
/// is the seam that could silently come apart: the screen's view model is built from the vault in
/// <c>OnVaultChanged</c>, so a vault opened by any path other than the one this test takes would leave
/// the nav rail pointing at a null.
/// </remarks>
[Fact]
public async Task ThePinSectionIsReachableAndTakesItsTurn()
public async Task ThePinsScreenExistsForAsLongAsTheKeychainDoes()
{
await UnlockedAsync();
var pins = shell.KnownHostsScreen.ShouldNotBeNull("unlocking builds it");
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
shell.IsKnownHostsShowing.ShouldBeTrue();
shell.IsVaultShowing.ShouldBeFalse();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await shell.Vault!.LoadAsync(Token);
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
await shell.LockCommand.ExecuteAsync(null);
shell.KnownHostsScreen.ShouldBeNull("it goes with the keychain it was built from");
}
/// <remarks>
/// The workflow the screen exists for: an operator publishes a fingerprint and somebody wants to know
/// whether it is the one they approved. A filter that searched only host names would answer a different
/// question, so this is the assertion that keeps the fingerprint in the search.
/// </remarks>
[Fact]
public async Task ThePinsScreenFiltersByFingerprintAsWellAsByHost()
{
await UnlockedAsync();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:aaaaaaaa"), Token);
await knownHosts.TrustAsync(
new HostKeyPresentation("web.internal", 22, "ssh-ed25519", "SHA256:bbbbbbbb"), Token);
await shell.Vault!.LoadAsync(Token);
var pins = shell.KnownHostsScreen!;
pins.VisiblePins.Count.ShouldBe(2);
pins.Filter = "bbbb";
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("web.internal");
pins.Filter = "db.";
pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");
pins.Filter = "nothing matches this";
pins.VisiblePins.ShouldBeEmpty();
pins.EmptyMessage.ShouldContain("matches that", Case.Insensitive);
}
/// <remarks>
/// Forgetting is forwarded to the vault's command, which is the one wired into the reload and the push.
/// What this covers is the forwarding: that the screen's own selection reaches it.
/// </remarks>
[Fact]
public async Task ForgettingFromThePinsScreen_WithdrawsTheTrust()
{
await UnlockedAsync();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await shell.Vault!.LoadAsync(Token);
var pins = shell.KnownHostsScreen!;
pins.Selected = pins.VisiblePins[0];
await pins.ForgetSelectedCommand.ExecuteAsync(null);
pins.VisiblePins.ShouldBeEmpty();
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
// ---- Generating a key ----
/// <remarks>
/// The property that keeps this feature from being a second way to write a key: generating fills the
/// editor and stops. Everything after that — validation, encoding, the outbox, the push — is the path a
/// pasted key already takes, and SAVE is still the only thing that writes.
/// </remarks>
[Fact]
public async Task GeneratingAKey_FillsTheEditorAndStoresNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.NewGeneratedKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeTrue();
vault.GenerateComment = "deploy@laptop";
vault.ShowsKnownHosts.ShouldBeTrue();
vault.ShowsAll.ShouldBeFalse();
vault.ShowsKeys.ShouldBeFalse();
vault.ShowsCredentials.ShouldBeFalse();
await vault.GenerateKeyCommand.ExecuteAsync(null);
vault.IsGeneratingKey.ShouldBeFalse();
vault.IsEditingKey.ShouldBeTrue("what it made lands in the editor, unsaved");
vault.KeyEditorLabel.ShouldBe("deploy@laptop");
vault.KeyEditorPrivateKey.ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
vault.KeyEditorPublicKey.ShouldStartWith("ssh-ed25519 ");
vault.KeyEditorPublicKey.ShouldEndWith("deploy@laptop");
vault.Keys.ShouldBeEmpty("nothing is stored until SAVE");
vault.PendingChanges.ShouldBe(0);
vault.Status.ShouldContain("SAVE");
// And then it saves through the ordinary path, which is the other half of the claim.
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem().Label.ShouldBe("deploy@laptop");
}
[Fact]
public async Task CancellingTheGenerateForm_MakesNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewGeneratedKeyCommand.Execute(null);
vault.CancelGenerateKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeFalse();
vault.IsEditingKey.ShouldBeFalse();
vault.Keys.ShouldBeEmpty();
}
/// <remarks>
/// A machine with no clipboard reports itself rather than appearing to have copied. This shell is built
/// without one, which is what makes the case reachable at all.
/// </remarks>
[Fact]
public async Task CopyingAPublicKey_WithNoClipboard_SaysSo()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewGeneratedKeyCommand.Execute(null);
await vault.GenerateKeyCommand.ExecuteAsync(null);
await vault.SaveKeyCommand.ExecuteAsync(null);
vault.Section = VaultSection.Keys;
vault.SelectedVaultItem = vault.VaultItems[0];
vault.SelectedItemIsKey.ShouldBeTrue();
await vault.CopyPublicKeyCommand.ExecuteAsync(null);
vault.Status.ShouldContain("no clipboard", Case.Insensitive);
}
// ---- Importing ssh_config ----
/// <remarks>
/// The whole of the import, from a file on disk to hosts on the server. What it establishes beyond the
/// parser's own suite is the half that suite cannot reach: that scanning writes nothing, that importing
/// goes through the ordinary create-and-push path, and that a host already in the keychain arrives
/// unticked rather than being silently duplicated.
/// </remarks>
[Fact]
public async Task ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var sshDirectory = Path.Combine(directory, "ssh");
Directory.CreateDirectory(sshDirectory);
// db.internal:deploy is what AddHostAsync creates, so the first entry is a host already held.
await File.WriteAllTextAsync(
Path.Combine(sshDirectory, "config"),
"""
Host already-here
HostName db.internal
User deploy
Host web-01
HostName web-01.internal
User deploy
Port 2222
""",
Token);
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.Rows.Count.ShouldBe(2);
vault.Hosts.Count.ShouldBe(1, "scanning stores nothing");
var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
import.Rows
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
.IsSelected.ShouldBeTrue();
await import.ImportCommand.ExecuteAsync(null);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
imported.Address.ShouldBe("deploy@web-01.internal:2222");
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
// Through the ordinary path, which is the point of routing it through the vault: it reached the
// server without anything pressing Sync.
server.LiveRowCount.ShouldBe(2);
}
[Fact]
public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing()
{
await UnlockedAsync();
var import = new ImportViewModel(
shell.Vault!,
new SshConfigLocator(Path.Combine(directory, "nothing-here")));
await import.ScanCommand.ExecuteAsync(null);
import.Rows.ShouldBeEmpty();
import.Status.ShouldContain("no", Case.Insensitive);
}
// ---- Filtering the host sidebar ----
@@ -2245,6 +2706,403 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost));
}
// ---- Groups ----
/// <remarks>
/// The property that makes this feature free to ignore. Somebody with eleven machines and no wish to file
/// them should see the list they have always seen — not a heading telling them their hosts are ungrouped.
/// </remarks>
[Fact]
public async Task AVaultWithNoGroups_DrawsNoHeadings()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
vault.HasGroups.ShouldBeFalse();
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
vault.SidebarRows.Count.ShouldBe(vault.VisibleHosts.Count);
}
[Fact]
public async Task FilingAHostIntoAGroup_PutsItUnderThatHeading()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var rows = vault.SidebarRows.ToArray();
// One group, so: its heading, its one host, then the ungrouped heading and the other host.
rows[0].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("production");
rows[1].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("prod-db");
rows[2].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("UNGROUPED");
rows[3].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("stage-web");
}
/// <remarks>
/// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder
/// somebody made and can put things in, the second is an absence of search results — and a heading with
/// nothing under it reads as a group that has lost its contents.
/// </remarks>
[Fact]
public async Task AGroupEmptiedByTheFilter_LosesItsHeading()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await AddGroupAsync(vault, "staging");
await FileAsync(vault, "prod-db", "production");
Headings(vault).ShouldBe(["production", "staging"], "an empty group keeps its heading");
vault.HostFilter = "nothing matches this";
Headings(vault).ShouldBe(["production", "staging"]);
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty();
}
[Fact]
public async Task FoldingAGroupAwayHidesItsHostsAndSurvivesAReload()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("the group is folded away");
// Folded state is held by group id rather than on the row, because a background sync rebuilds every
// row once a minute and a flag on one would be forgotten the first time it did.
await vault.LoadAsync(Token);
vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("and a reload does not unfold it");
}
/// <remarks>
/// The heading is a row in the same <c>ListBox</c> as the hosts, so the control will select it. Nothing
/// else in the application acts on a heading — CONNECT, EDIT and DELETE all read the host selection — so
/// clicking one has to leave that selection exactly where it was.
/// </remarks>
[Fact]
public async Task SelectingAHeading_LeavesTheHostSelectionAlone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var host = vault.Hosts.Single();
vault.SelectedHost = host;
vault.SelectedSidebarRow = vault.SidebarRows.OfType<SidebarGroupHeader>().First();
vault.SelectedHost.ShouldBeSameAs(host);
vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
}
/// <remarks>
/// Deleting a group deliberately does not rewrite the hosts in it — one delete would otherwise become N
/// writes, N outbox rows and N chances to merge against a change nobody made — so those hosts keep an id
/// that resolves to nothing. "The group is gone" and "this host is in no group" have to look the same,
/// because to the person reading the list they are the same thing.
/// </remarks>
[Fact]
public async Task DeletingAGroup_LeavesItsHostsUnderTheUngroupedHeading()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var groupId = vault.Groups.Single().EntityId;
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().Usage
.ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Groups.ShouldBeEmpty();
vault.HasGroups.ShouldBeFalse();
// The host keeps the id, which is what makes this cheap; the sidebar is what resolves it to nothing.
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
}
/// <remarks>
/// The picker keeps a placeholder entry for a group the vault no longer has, exactly as the
/// authentication picker does for a deleted key. Without it the picker would open on "No group" and
/// somebody editing the host's port would unfile it by saving.
/// </remarks>
[Fact]
public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var groupId = vault.Groups.Single().EntityId;
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedGroup.ShouldNotBeNull().EntityId.ShouldBe(groupId);
vault.EditorPort = 2222;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId, "an unrelated edit must not unfile the host");
}
[Fact]
public async Task RenamingAGroup_RenamesItsHeading()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.SelectedGroup = vault.Groups.Single();
vault.EditGroupCommand.Execute(null);
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
vault.GroupEditorLabel = "live";
await vault.SaveGroupCommand.ExecuteAsync(null);
Headings(vault).ShouldBe(["live"]);
vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved");
}
// ---- Snippets ----
/// <remarks>
/// The default that the whole feature's safety rests on. A snippet somebody writes without thinking
/// about the flag has to be one that gets typed and waits, because the alternative is a command that
/// runs the first time it is clicked.
/// </remarks>
[Fact]
public async Task ANewSnippet_DoesNotRunOnItsOwn()
{
await UnlockedAsync();
var vault = shell.Vault!;
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
snippets.NewCommand.Execute(null);
snippets.EditorRunsOnInsert.ShouldBeFalse("the box starts off");
snippets.EditorLabel = "restart the api";
snippets.EditorCommand = "sudo systemctl restart dodossh-api";
await snippets.SaveCommand.ExecuteAsync(null);
vault.Snippets.ShouldHaveSingleItem().RunsOnInsert.ShouldBeFalse();
}
/// <remarks>
/// A here-document's terminator has to arrive on a line of its own with nothing after it. Trim the
/// trailing newline and the shell waits for one that never comes, which reads to the user as the snippet
/// having hung the terminal — so the command is stored exactly as typed, in the same way key armour is.
/// </remarks>
[Fact]
public async Task ASnippetsText_IsStoredExactlyAsTyped()
{
await UnlockedAsync();
var vault = shell.Vault!;
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
const string Command = "cat <<'EOF' > /etc/motd\n welcome \nEOF\n";
snippets.NewCommand.Execute(null);
snippets.EditorLabel = " set the motd ";
snippets.EditorCommand = Command;
await snippets.SaveCommand.ExecuteAsync(null);
var stored = vault.Snippets.ShouldHaveSingleItem();
stored.Snippet.Command.ShouldBe(Command);
stored.Label.ShouldBe("set the motd", "the name is trimmed, and only the name");
}
[Fact]
public async Task InsertingASnippet_SendsItsTextToTheSelectedTabWithoutRunningIt()
{
await UnlockedAsync();
var sent = new List<(uint SessionId, string Text, bool Execute)>();
var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent);
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
snippets.Selected = snippets.Visible.Single();
snippets.CanInsert.ShouldBeTrue();
await snippets.InsertCommand.ExecuteAsync(null);
var delivered = sent.ShouldHaveSingleItem();
delivered.SessionId.ShouldBe(7u);
delivered.Text.ShouldBe("uptime");
delivered.Execute.ShouldBeFalse("INSERT types the command and stops");
}
/// <remarks>
/// RUN is offered only for a snippet whose own flag says it runs, so that "this one runs" is a decision
/// taken once while writing it. Pressing the command for a snippet without the flag has to do nothing —
/// not throw, and above all not send.
/// </remarks>
[Fact]
public async Task RunningASnippet_IsRefusedUnlessTheSnippetSaysItRuns()
{
await UnlockedAsync();
var sent = new List<(uint SessionId, string Text, bool Execute)>();
var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent);
await AddSnippetAsync(snippets, "safe", "ls -la", runs: false);
await AddSnippetAsync(snippets, "armed", "sudo reboot", runs: true);
snippets.Selected = snippets.Visible.Single(row => !row.RunsOnInsert);
snippets.SelectionRuns.ShouldBeFalse();
await snippets.RunCommand.ExecuteAsync(null);
sent.ShouldBeEmpty("this snippet is not one that runs");
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
snippets.SelectionRuns.ShouldBeTrue();
await snippets.RunCommand.ExecuteAsync(null);
sent.ShouldHaveSingleItem().Execute.ShouldBeTrue();
}
[Fact]
public async Task InsertingWithNoTerminalOpen_SaysSoAndSendsNothing()
{
await UnlockedAsync();
var sent = new List<(uint SessionId, string Text, bool Execute)>();
var snippets = SnippetsOver(shell.Vault!, InsertTarget.None, sent);
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
snippets.Selected = snippets.Visible.Single();
snippets.CanInsert.ShouldBeFalse();
snippets.InsertLabel.ShouldBe("NO TERMINAL OPEN");
await snippets.InsertCommand.ExecuteAsync(null);
sent.ShouldBeEmpty();
snippets.Status.ShouldContain("Open a terminal first", Case.Sensitive);
}
/// <remarks>
/// The transport drops frames for a pane nothing is listening to, so a send at a tab whose remote hung
/// up succeeds exactly as loudly as one at a live tab. That is why the insert reports back — and why the
/// screen has to say so rather than leaving somebody to wonder whether the command landed.
/// </remarks>
[Fact]
public async Task InsertingIntoATabThatIsNoLongerConnected_SaysSo()
{
await UnlockedAsync();
var snippets = new SnippetsViewModel(
shell.Vault!,
() => new InsertTarget(7, "prod-db"),
static (_, _, _, _) => Task.FromResult(false));
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
snippets.Selected = snippets.Visible.Single();
await snippets.InsertCommand.ExecuteAsync(null);
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
}
private static SnippetsViewModel SnippetsOver(
VaultViewModel vault,
InsertTarget target,
List<(uint SessionId, string Text, bool Execute)> sent) =>
new(
vault,
() => target,
(sessionId, text, execute, _) =>
{
sent.Add((sessionId, text, execute));
return Task.FromResult(true);
});
private static async Task AddSnippetAsync(
SnippetsViewModel snippets,
string label,
string command,
bool runs)
{
snippets.NewCommand.Execute(null);
snippets.EditorLabel = label;
snippets.EditorCommand = command;
snippets.EditorRunsOnInsert = runs;
await snippets.SaveCommand.ExecuteAsync(null);
}
private static string[] Headings(VaultViewModel vault) =>
[.. vault.SidebarRows.OfType<SidebarGroupHeader>()
.Where(header => header.GroupId is not null)
.Select(header => header.Label)];
private static async Task AddGroupAsync(VaultViewModel vault, string label)
{
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <summary>Files a host into a group the way a user can: through the host's own editor.</summary>
private static async Task FileAsync(VaultViewModel vault, string host, string group)
{
vault.SelectedHost = vault.Hosts.Single(
row => string.Equals(row.Label, host, StringComparison.Ordinal));
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, group, StringComparison.Ordinal));
await vault.SaveHostCommand.ExecuteAsync(null);
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
@@ -0,0 +1,276 @@
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
/// </summary>
/// <remarks>
/// <para>
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
/// lookup with a key of their own.
/// </para>
/// <para>
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
/// sharing works, not that verification does.
/// </para>
/// </remarks>
public sealed class TeamSharingTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
private readonly FakeSshConnectionFactory ssh = new();
private string directory = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
var paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
ssh,
TimeProvider.System);
shell = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
(_, _) => Task.FromResult<IVaultServer>(server),
TimeProvider.System,
NSubstitute.Substitute.For<ISftpSessionFactory>(),
CheapProfile);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
caches.Dispose();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// A cache file the process has not finished releasing. The directory is under the temp path
// and named per run, so leaving it costs a few kilobytes and never collides.
}
}
/// <remarks>
/// The whole point of a team, in one test. Note what the status line says after the add and before
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
/// than let a user believe the credential is already with their colleague.
/// </remarks>
[Fact]
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.Vaults.Count.ShouldBe(1, teams.Status);
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.Count.ShouldBe(2, teams.Status);
teams.Status.ShouldContain("cannot read anything yet");
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
teams.Status.ShouldContain("Shared");
// The one thing verification cannot promise, said in the same breath as the success.
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
}
/// <remarks>
/// <para>
/// The test this whole design exists for. A server that wants to read a team's vault only has to
/// answer one directory lookup with a key it holds the private half of — so the client reads the
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
/// offered is in there unchanged.
/// </para>
/// <para>
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
/// worse than no check at all, because the interface would have said it was verified.
/// </para>
/// </remarks>
[Fact]
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.InviteEmail = "mallory@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
server.CorruptKeyLog = true;
await teams.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldBeEmpty();
teams.Status.ShouldContain("Did not share");
teams.Status.ShouldContain("key log");
}
/// <remarks>
/// A vault created here is usable here, without a relock. The key was generated in this process, so
/// making the user lock and unlock to reach the vault they just made would be asking them to work
/// around bookkeeping.
/// </remarks>
[Fact]
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
var session = shell.Vault!.Session;
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
// And it is offered as somewhere to file a new item, which is what makes it worth having.
await shell.Vault.LoadAsync(Token);
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
shell.Vault.HasVaultChoice.ShouldBeTrue();
}
/// <remarks>
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
/// with a half-typed host on screen would move it.
/// </remarks>
[Fact]
public async Task AHostFiledIntoATeamVault_StaysThere()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var teamVaultId = teams.Vaults[0].VaultId;
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
// Moved back after the editor opened. The host must still land in the team's vault.
vault.SelectedTargetVault =
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
var row = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
row.VaultId.ShouldBe(teamVaultId);
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);
teams.NewTeamCommand.Execute(null);
teams.NewTeamName = name;
teams.NewTeamSlug = slug;
await teams.CreateTeamCommand.ExecuteAsync(null);
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
}
/// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
/// that entry is what the client verifies its own directory answer against.
/// </remarks>
private async Task UnlockedAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
}
@@ -0,0 +1,200 @@
using Avalonia.Threading;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using NSubstitute;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// What may be queued for transfer, and what is said about the rest.
/// </summary>
/// <remarks>
/// <para>
/// This is the whole of what drag and drop decides. The handlers on the screen extract paths or rows from a
/// drop and hand them here; every rule about which of them can be moved, which are skipped and what the
/// status line says lives in the view model, where it needs no window.
/// </para>
/// <para>
/// <b>What these cannot cover is the drag itself.</b> Headless Avalonia has no native window and cannot
/// synthesise a platform drag, so a test that pretended to drop a file from the file manager would pass
/// while confirming nothing. The wiring is verified by hand — see <c>docs/manual-checks.md</c> — and what
/// is automated is the half that a person checking by eye would most easily get wrong: the counting.
/// </para>
/// </remarks>
public sealed class TransferQueueingTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-drop-{Guid.CreateVersion7():N}");
private readonly TransfersViewModel transfers =
new(Substitute.For<ISftpSessionFactory>(), TimeProvider.System);
public TransferQueueingTests() => Directory.CreateDirectory(directory);
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
/// <remarks>
/// The refusal that has to happen before anything else: there is nowhere to put a file until a host is
/// connected, and a queue that filled up first would start failing the moment one was.
/// </remarks>
[Fact]
public void DroppingFilesWhileDisconnected_QueuesNothingAndSaysWhy()
{
transfers.IsConnected = false;
transfers.QueueUploads([File("one.txt")]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Connect to a host first");
}
[Fact]
public void DroppingSeveralFiles_QueuesEachOfThem()
{
Connected();
transfers.QueueUploads([File("one.txt"), File("two.txt"), File("three.txt")]);
Queued().Count.ShouldBe(3);
transfers.Status.ShouldContain("3 files");
transfers.Status.ShouldContain("/srv/app");
}
/// <remarks>
/// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
/// looks exactly like a transfer that failed to start — so it is counted and reported.
/// </remarks>
[Fact]
public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueUploads([File("one.txt"), folder]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 file");
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// The paths in an operating-system drop come from another process and are not obliged to still be
/// right by the time the drop lands.
/// </remarks>
[Fact]
public void DroppingAFileThatHasGone_SkipsItAndSaysSo()
{
Connected();
transfers.QueueUploads([File("one.txt"), Path.Combine(directory, "never-existed.txt")]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 item was no longer there");
}
[Fact]
public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueUploads([folder]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Nothing was queued");
transfers.Status.ShouldContain("1 folder was skipped");
}
[Fact]
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
{
Connected();
transfers.QueueDownloads([RemoteFile("one.log"), RemoteFile("two.log")]);
Queued().Count.ShouldBe(2);
transfers.Status.ShouldContain("2 files");
transfers.Status.ShouldContain("download into");
}
[Fact]
public void DroppingARemoteDirectory_SkipsItAndSaysSo()
{
Connected();
transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
/// says they still do.
/// </remarks>
[Fact]
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
{
Connected();
transfers.SelectedRemoteEntry = RemoteFile("one.log");
transfers.DownloadCommand.Execute(null);
Queued().ShouldHaveSingleItem();
// And refuses a directory in the same words, rather than with the button's own message.
transfers.SelectedRemoteEntry = RemoteDirectory("logs");
transfers.DownloadCommand.Execute(null);
Queued().Count.ShouldBe(1);
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
/// <remarks>
/// <c>TransfersViewModel</c> adds a row from the queue's own <c>Changed</c> event, which it marshals
/// through <c>Dispatcher.UIThread</c> because the queue raises it from a pump thread. There is no
/// Avalonia application here to drain that, so the posts are run by hand — the alternative is asserting
/// on the status line alone, which is a string this code wrote about itself and proves nothing about
/// anything having been enqueued.
/// </remarks>
private IReadOnlyList<TransferRowViewModel> Queued()
{
Dispatcher.UIThread.RunJobs();
return transfers.Transfers;
}
private void Connected()
{
transfers.IsConnected = true;
transfers.RemotePath = "/srv/app";
transfers.LocalPath = directory;
}
private string File(string name)
{
var path = Path.Combine(directory, name);
System.IO.File.WriteAllText(path, "contents");
return path;
}
private static RemoteEntryRowViewModel RemoteFile(string name) => new(
new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.File, 128, DateTimeOffset.UnixEpoch, "-rw-r--r--"));
private static RemoteEntryRowViewModel RemoteDirectory(string name) => new(
new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.Directory, 0, DateTimeOffset.UnixEpoch, "drwxr-xr-x"));
}
@@ -1,6 +1,8 @@
using System.Runtime.Versioning;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Session.Tests;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The real TPM-backed store, as far as it can be exercised without a person. Which is not far.
@@ -37,7 +39,7 @@ public sealed class WindowsDeviceKeyStoreTests
// provider reports itself present on machines where creating a key then fails.
SkipUnlessSupported();
var store = DeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
store.ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
@@ -64,7 +66,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported()
{
var supported = OperatingSystem.IsWindows()
&& DeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
is WindowsDeviceKeyStore;
if (!supported)
@@ -474,6 +474,8 @@
"Avalonia.Fonts.Inter": "[12.1.1, )",
"Avalonia.Themes.Fluent": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Import": "[1.0.0, )",
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Shell": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
@@ -487,6 +489,21 @@
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.import": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Domain": "[1.0.0, )"
}
},
"dodossh.client.objectstore": {
"type": "Project",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, )",
"AWSSDK.S3": "[4.0.101.6, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.session": {
"type": "Project",
"dependencies": {
@@ -495,7 +512,8 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
"DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.shell": {
@@ -503,6 +521,8 @@
"dependencies": {
"Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Import": "[1.0.0, )",
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
@@ -512,6 +532,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -607,6 +628,21 @@
"Avalonia": "12.1.1"
}
},
"AWSSDK.Core": {
"type": "CentralTransitive",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "CentralTransitive",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
@@ -10,6 +10,9 @@ internal static class HostFactory
/// <summary>A vault SSH key id, for the hosts that bind one.</summary>
internal static Guid DeployKey { get; } = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e03");
/// <summary>A group id, for the hosts that are filed under one.</summary>
internal static Guid Production { get; } = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04");
internal static HostSecret Host(
string label = "prod-db",
string hostname = "db.internal",
@@ -20,7 +23,8 @@ internal static class HostFactory
(string Name, string Value)[]? options = null,
bool relayEnabled = false,
Guid? sshKeyId = null,
Guid? credentialId = null) =>
Guid? credentialId = null,
Guid? groupId = null) =>
new()
{
Label = label,
@@ -35,5 +39,6 @@ internal static class HostFactory
RelayEnabled = relayEnabled,
SshKeyId = sshKeyId,
CredentialId = credentialId,
GroupId = groupId,
};
}
@@ -0,0 +1,101 @@
using System.Text;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// A group: one name, and the reasons it is only that.
/// </summary>
/// <remarks>
/// There is very little behaviour here to test, which is itself the design — every field that was considered
/// and left out (a parent, a member list) was left out because of what it would do to the merge. What these
/// tests pin is that the envelope round-trips, that a nameless group cannot be stored, and that renaming the
/// same group on two machines is reported rather than silently resolved.
/// </remarks>
public sealed class HostGroupSecretTests
{
[Fact]
public void AGroup_RoundTrips()
{
var group = new HostGroupSecret { Label = "production" };
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.Group.ShouldBe(group);
document.SchemaVersion.ShouldBe(HostGroupSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void AGroupWithNoName_IsRefused(string label)
{
new HostGroupSecret { Label = label }.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull();
}
[Fact]
public void AGroupWrittenByANewerClient_IsReadableButNotWritableHere()
{
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":99,"label":"production","colour":"a field this build has never heard of"}
""");
HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Group.Label.ShouldBe("production");
document.IsReadOnly.ShouldBeTrue();
}
[Fact]
public void AnUnnamedPayload_FailsToDecodeRatherThanProducingABlankGroup()
{
// Failing closed, as every codec in this folder does: a group with no name is indistinguishable in
// the sidebar from the ungrouped heading it would sit beside.
var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":1}""");
HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse();
document.ShouldBeNull();
}
[Fact]
public void TwoDifferentRenames_AreReportedWithBothNames()
{
var ancestor = new HostGroupSecret { Label = "production" };
var result = HostGroupSecretMerge.Merge(
ancestor,
ancestor with { Label = "prod" },
ancestor with { Label = "live" });
result.Merged.Label.ShouldBe("live");
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(HostGroupSecret.Label));
conflict.Kept.ShouldBe("live");
conflict.Discarded.ShouldBe("prod");
}
/// <remarks>
/// The case that would collide if membership were held on the group instead of on each host: two people
/// filing two different machines into one group at the same time. It cannot reach the merge at all,
/// because neither of those actions writes to this item.
/// </remarks>
[Fact]
public void FilingHostsIntoAGroup_DoesNotTouchTheGroup()
{
var ancestor = new HostGroupSecret { Label = "production" };
var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor);
result.HasConflicts.ShouldBeFalse();
result.Merged.ShouldBe(ancestor);
}
}
@@ -15,10 +15,10 @@ namespace DodoSSH.Client.Domain.Tests;
public sealed class HostSecretCodecTests
{
/// <remarks>
/// "Full" cannot mean every field any more: the two bindings are mutually exclusive, so a host may carry
/// a key or a credential and never both. This one carries the credential because that is the newer of
/// the two and therefore the highest schema version a valid host can reach; the key-bound case has its
/// own version test below.
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
/// carry a key or a credential and never both. This one carries the credential, because that is the newer
/// of the two, plus a group — which is orthogonal to both and is what makes this host reach the highest
/// schema version a valid host can.
/// </remarks>
[Fact]
public void AFullHost_RoundTrips()
@@ -34,7 +34,8 @@ public sealed class HostSecretCodecTests
jumps: [Bastion, Relay],
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
relayEnabled: true,
credentialId: credentialId);
credentialId: credentialId,
groupId: Production);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
@@ -91,7 +92,7 @@ public sealed class HostSecretCodecTests
[Fact]
public void AKeyBoundHost_IsNotDraggedOntoTheCredentialVersion()
{
// The point of the ladder. Adding credentials must not make every key-bound host in every vault
// The point of the rule. Adding credentials must not make every key-bound host in every vault
// read-only on a client that understands keys perfectly well.
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document)
@@ -103,6 +104,48 @@ public sealed class HostSecretCodecTests
version.ShouldBeLessThan(HostSecretCodec.CredentialIdSchemaVersion);
}
[Fact]
public void AGroupedHost_IsWrittenAtTheVersionThatIntroducedGroups()
{
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(groupId: Production)), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion);
document.Host.GroupId.ShouldBe(Production);
}
/// <remarks>
/// <para>
/// The case that turned the version rule from a ladder into a maximum, and the one that would have shipped
/// a real defect. A group is orthogonal to the two authentication bindings — a host may carry a credential
/// and a group at once — so a <c>switch</c> returning the first match would have answered 3 for this host:
/// a version that has no concept of the group the same write just stored.
/// </para>
/// <para>
/// What that produces is worse than a wrong number. An older client reads schema 3, concludes the payload
/// holds nothing it does not understand, offers to edit the host, and drops the group on save — silently,
/// on every machine that has not been upgraded. Asserted for both bindings, because the ladder's order
/// meant only one of the two would have been caught by a single case.
/// </para>
/// </remarks>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AHostThatIsBothBoundAndGrouped_IsWrittenAtTheHigherOfTheTwo(bool byCredential)
{
var host = byCredential
? Host(credentialId: Guid.CreateVersion7(), groupId: Production)
: Host(sshKeyId: DeployKey, groupId: Production);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion);
document.Host.ShouldBe(host);
}
[Fact]
public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne()
{
@@ -0,0 +1,175 @@
using System.Text;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// A saved command: what it stores, and what it refuses to change about it.
/// </summary>
/// <remarks>
/// Two things carry weight. That the text survives a round trip untouched, because a shell is not forgiving
/// about whitespace it did not expect. And that <see cref="SnippetSecret.RunsOnInsert"/> only ever becomes
/// true because somebody set it — never because a decode defaulted it, and never because a merge picked a
/// side.
/// </remarks>
public sealed class SnippetSecretTests
{
[Fact]
public void ASnippet_RoundTrips()
{
var snippet = new SnippetSecret
{
Label = "restart the api",
Command = "sudo systemctl restart dodossh-api",
Notes = "check the on-call rota first",
RunsOnInsert = true,
};
SnippetSecretCodec.TryDecode(SnippetSecretCodec.Encode(snippet), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.Snippet.ShouldBe(snippet);
document.SchemaVersion.ShouldBe(SnippetSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
/// <remarks>
/// The property the whole type is built around. A heredoc's terminator has to arrive on a line of its
/// own with nothing after it; trim the trailing newline and the shell waits for one that never comes,
/// which looks to the user like the snippet hanging the terminal.
/// </remarks>
[Theory]
[InlineData(" indented\n")]
[InlineData("cat <<'EOF'\nline one\nEOF\n")]
[InlineData("first\r\nsecond\r\n")]
[InlineData("trailing space ")]
[InlineData("")]
public void TheCommandSurvivesAsItWasWritten(string command)
{
var snippet = new SnippetSecret { Label = "verbatim", Command = command };
SnippetSecretCodec.TryDecode(SnippetSecretCodec.Encode(snippet), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull().Snippet.Command.ShouldBe(command);
}
[Fact]
public void ARunFlagThatIsMissingFromThePayload_ReadsAsOff()
{
// The safe direction, and the one a truncated or hand-written payload has to fall in: a snippet
// whose flag could not be read must not be one that runs on its own.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":1,"label":"restart","command":"sudo reboot"}
""");
SnippetSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Snippet.RunsOnInsert.ShouldBeFalse();
}
[Theory]
[InlineData("", "echo hi")]
[InlineData(" ", "echo hi")]
[InlineData("named", "")]
public void AnUnusableSnippet_IsRefused(string label, string command)
{
new SnippetSecret { Label = label, Command = command }
.TryValidate(out var reason)
.ShouldBeFalse();
reason.ShouldNotBeNull();
}
/// <remarks>
/// Whitespace is a legitimate command — a bare space at a prompt, an indented continuation line — so the
/// blank check on the text is deliberately <c>IsNullOrEmpty</c> rather than the whitespace-aware one used
/// on the label.
/// </remarks>
[Fact]
public void ACommandThatIsNothingButWhitespace_IsAllowed()
{
new SnippetSecret { Label = "a space", Command = " " }.TryValidate(out _).ShouldBeTrue();
}
// ---- The merge ----
[Fact]
public void AnUncontestedEdit_IsTakenFromWhicheverSideMadeIt()
{
var ancestor = Snippet();
var local = ancestor with { Notes = "now with a note" };
var result = SnippetSecretMerge.Merge(ancestor, local, ancestor);
result.HasConflicts.ShouldBeFalse();
result.Merged.Notes.ShouldBe("now with a note");
}
/// <remarks>
/// <para>
/// The property that matters about this field, stated over every combination there is: a merge never
/// produces a snippet that runs on its own unless one of the two sides asked for one. It holds for a
/// structural reason rather than a defensive one — a three-way clash needs both sides to differ from the
/// ancestor and from each other, which two values cannot do — and that is exactly why it is worth a test.
/// The reasoning is easy to lose, and the field is the whole safety story.
/// </para>
/// <para>
/// An earlier draft of the merge special-cased a clash here to resolve to <see langword="false"/>. This
/// test is what showed the branch was unreachable.
/// </para>
/// </remarks>
[Theory]
[InlineData(false, false, false, false)]
[InlineData(false, true, false, true)]
[InlineData(false, false, true, true)]
[InlineData(false, true, true, true)]
[InlineData(true, false, true, false)]
[InlineData(true, true, false, false)]
[InlineData(true, true, true, true)]
public void TheRunFlag_OnlyEverBecomesTrueBecauseASideAskedForIt(
bool ancestor,
bool local,
bool remote,
bool expected)
{
var start = Snippet() with { RunsOnInsert = ancestor };
var result = SnippetSecretMerge.Merge(
start,
start with { RunsOnInsert = local },
start with { RunsOnInsert = remote });
result.Merged.RunsOnInsert.ShouldBe(expected);
result.Conflicts.ShouldNotContain(
conflict => conflict.Field == nameof(SnippetSecret.RunsOnInsert),
"a two-valued field cannot produce a three-way conflict");
}
[Fact]
public void TwoDifferentEditsToTheCommand_AreReportedWithBothTexts()
{
// Nothing is redacted here, unlike a credential: a snippet is text somebody wrote on purpose, and a
// notice that hid the discarded version would leave them unable to tell which one survived.
var ancestor = Snippet();
var result = SnippetSecretMerge.Merge(
ancestor,
ancestor with { Command = "systemctl restart api" },
ancestor with { Command = "systemctl reload api" });
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(SnippetSecret.Command));
conflict.Kept.ShouldBe("systemctl reload api");
conflict.Discarded.ShouldBe("systemctl restart api");
}
private static SnippetSecret Snippet() => new()
{
Label = "restart the api",
Command = "sudo systemctl restart dodossh-api",
};
}
@@ -0,0 +1,54 @@
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// Reading a creation time back out of an identifier this client minted.
/// </summary>
/// <remarks>
/// The whole value of this helper is that it is the only creation time any vault item has, so the two cases
/// that matter are that a real id gives a plausible answer and that anything else gives none. A version 4
/// id answering with a date would be worse than answering nothing: it would be a plausible wrong date on a
/// screen somebody is using to decide whether a pin is stale.
/// </remarks>
public sealed class Uuid7TimestampTests
{
[Fact]
public void AFreshlyMintedId_CarriesTheMomentItWasMade()
{
// TimeProvider, because DateTimeOffset.UtcNow is banned repo-wide — see BannedSymbols.txt. The real
// clock rather than a fake one is the point here: what is being checked is that the id Guid mints
// for itself, from a clock nothing in this test controls, reads back as the moment it happened.
var before = TimeProvider.System.GetUtcNow().AddSeconds(-5);
var id = Guid.CreateVersion7();
var after = TimeProvider.System.GetUtcNow().AddSeconds(5);
var stamped = Uuid7Timestamp.Of(id).ShouldNotBeNull();
stamped.ShouldBeGreaterThanOrEqualTo(before);
stamped.ShouldBeLessThanOrEqualTo(after);
}
/// <remarks>
/// The one that catches a little-endian read. Guid stores its first three fields in host order, so
/// taking the bytes the obvious way scrambles precisely the six this reads — and produces dates tens of
/// thousands of years out rather than throwing, which is why an ordering assertion is worth having on
/// top of the range check above.
/// </remarks>
[Fact]
public void TwoIdsMintedInOrder_ReadBackInThatOrder()
{
var first = Guid.CreateVersion7(DateTimeOffset.FromUnixTimeMilliseconds(1_500_000_000_000));
var second = Guid.CreateVersion7(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000));
Uuid7Timestamp.Of(first).ShouldBe(DateTimeOffset.FromUnixTimeMilliseconds(1_500_000_000_000));
Uuid7Timestamp.Of(second).ShouldBe(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000));
}
[Fact]
public void AnIdThatIsNotVersionSeven_HasNoTimeToRead()
{
Uuid7Timestamp.Of(Guid.Parse("f81d4fae-7dec-41d0-a765-00a0c91e6bf6")).ShouldBeNull();
Uuid7Timestamp.Of(Guid.Empty).ShouldBeNull();
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The ssh_config reader, against strings. No disk, because the parser takes its include reader as a
parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour,
be covered by ordinary unit tests.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,353 @@
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Import.Tests;
/// <summary>
/// Reading real-world <c>ssh_config</c> shapes.
/// </summary>
/// <remarks>
/// Every case here is something a person's actual file contains. The value of an importer is entirely in
/// whether it agrees with <c>ssh</c> about what a file means — an importer that is nearly right produces
/// bookmarks that nearly connect, which is worse than one that refused.
/// </remarks>
public sealed class SshConfigParserTests
{
[Fact]
public void APlainBlock_ResolvesItsFields()
{
var import = Read("""
Host prod-db
HostName db.internal
User deploy
Port 2222
""");
var host = import.Hosts.ShouldHaveSingleItem();
host.Alias.ShouldBe("prod-db");
host.Hostname.ShouldBe("db.internal");
host.Username.ShouldBe("deploy");
host.Port.ShouldBe(2222);
host.Address.ShouldBe("deploy@db.internal:2222");
}
/// <remarks>
/// OpenSSH's own default, and the reason <c>Host db.internal</c> on its own works at all.
/// </remarks>
[Fact]
public void ABlockWithNoHostName_DialsItsAlias()
{
var host = Read("Host db.internal").Hosts.ShouldHaveSingleItem();
host.Hostname.ShouldBe("db.internal");
host.Port.ShouldBe(22);
host.Username.ShouldBeNull();
}
[Fact]
public void OneLineNamingSeveralHosts_YieldsOnePerName()
{
var import = Read("""
Host web1 web2 web3
User deploy
""");
import.Hosts.Select(host => host.Alias).ShouldBe(["web1", "web2", "web3"]);
import.Hosts.ShouldAllBe(host => host.Username == "deploy");
}
/// <remarks>
/// The rule that is not the intuitive one. A <c>Host *</c> block supplies what nothing earlier set and
/// cannot override what it did — get this backwards and every imported host takes the wildcard's
/// username.
/// </remarks>
[Fact]
public void AWildcardBlock_SuppliesDefaultsAndDoesNotOverride()
{
var import = Read("""
Host prod-db
HostName db.internal
User deploy
Host *
User root
Port 2200
""");
var host = import.Hosts.ShouldHaveSingleItem();
host.Username.ShouldBe("deploy", "the earlier block set it first");
host.Port.ShouldBe(2200, "nothing earlier set a port");
}
[Fact]
public void AWildcardBlock_IsNotItselfImported()
{
var import = Read("""
Host *.internal
User deploy
Host prod-db
HostName db.internal
""");
import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
import.SkippedPatterns.ShouldContain("*.internal", StringComparer.Ordinal);
import.Warnings.ShouldContain(warning => warning.Contains("*.internal", StringComparison.Ordinal));
}
[Fact]
public void AWildcardBlock_AppliesToTheHostsItMatches()
{
var import = Read("""
Host *.internal
User deploy
Host db.internal
Host web.example.com
""");
Alias(import, "db.internal").Username.ShouldBe("deploy");
Alias(import, "web.example.com").Username.ShouldBeNull();
}
[Theory]
[InlineData("Host prod-db\n Port=2222")]
[InlineData("Host prod-db\n Port = 2222")]
[InlineData("Host prod-db\n\tPort\t2222")]
public void TheFormsOpenSshAccepts_AllParse(string text)
{
Read(text).Hosts.ShouldHaveSingleItem().Port.ShouldBe(2222);
}
/// <remarks>
/// A whole-line <c>#</c> only, which is OpenSSH's rule: a <c>#</c> partway through a line is part of the
/// value, not the start of a comment. Treating it as one would silently truncate any value containing a
/// hash.
/// </remarks>
[Fact]
public void CommentsAndBlankLines_AreIgnored()
{
var import = Read("""
# my hosts
Host prod-db
HostName db.internal
#Host commented-out
# HostName nowhere
""");
import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
import.Hosts.ShouldNotContain(host => host.Alias == "commented-out");
}
[Fact]
public void AQuotedValue_KeepsItsSpaces()
{
var host = Read("""
Host prod-db
IdentityFile "C:\keys\my key"
""").Hosts.ShouldHaveSingleItem();
host.IdentityFiles.ShouldHaveSingleItem().ShouldBe(@"C:\keys\my key");
}
/// <remarks>
/// The keyword that legitimately repeats — <c>ssh</c> tries each in turn — so it accumulates rather than
/// being reported as a duplicate.
/// </remarks>
[Fact]
public void SeveralIdentityFiles_AreAllKept()
{
var host = Read("""
Host prod-db
IdentityFile ~/.ssh/id_ed25519
IdentityFile ~/.ssh/id_rsa
""").Hosts.ShouldHaveSingleItem();
host.IdentityFiles.Count.ShouldBe(2);
host.Warnings.ShouldBeEmpty("repeating IdentityFile is not a mistake");
}
/// <remarks>
/// <c>HostOptions</c> is unique by name and cannot represent a repeat, which its own remarks call an M1
/// limitation the import path must surface rather than quietly resolve. This is that surfacing.
/// </remarks>
[Fact]
public void ARepeatedKeyword_KeepsTheFirstAndSaysSo()
{
var host = Read("""
Host prod-db
ServerAliveInterval 30
serveraliveinterval 60
""").Hosts.ShouldHaveSingleItem();
host.Options
.Single(option => string.Equals(option.Name, "ServerAliveInterval", StringComparison.Ordinal))
.Value.ShouldBe("30");
host.Warnings.ShouldContain(warning =>
warning.Contains("ServerAliveInterval", StringComparison.OrdinalIgnoreCase));
}
/// <remarks>
/// Whether a <c>Match</c> applies depends on what is being connected to, or on a command's output.
/// Neither is knowable from the file, so its directives are dropped — the failure to avoid is
/// attributing them to whichever block happened to come before, which is what a parser that only knows
/// about <c>Host</c> does.
/// </remarks>
[Fact]
public void AMatchBlock_IsIgnoredAndItsDirectivesDoNotLeak()
{
var import = Read("""
Host prod-db
HostName db.internal
Match host bastion
User root
Port 2200
""");
var host = import.Hosts.ShouldHaveSingleItem();
host.Username.ShouldBeNull("a Match block's directives belong to nobody");
host.Port.ShouldBe(22);
import.Warnings.ShouldContain(warning => warning.Contains("Match", StringComparison.Ordinal));
}
[Fact]
public void AnInclude_IsReadInPlace()
{
var import = SshConfigResolver.Resolve(SshConfigParser.Parse(
"""
Include conf.d/*.conf
Host *
User fallback
""",
_ =>
[
"""
Host prod-db
HostName db.internal
User deploy
""",
]));
var host = import.Hosts.ShouldHaveSingleItem();
host.Alias.ShouldBe("prod-db");
host.Username.ShouldBe("deploy", "the include comes before the wildcard block that follows it");
}
/// <remarks>
/// A file that includes itself would otherwise be read to the depth cap, importing the same hosts
/// sixteen times — which reads as a bug in the importer rather than in the config.
/// </remarks>
[Fact]
public void AnIncludeCycle_StopsAndSaysSo()
{
var import = SshConfigResolver.Resolve(SshConfigParser.Parse(
"Include loop.conf",
_ =>
[
"""
Include loop.conf
Host prod-db
""",
]));
import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
import.Warnings.ShouldContain(warning => warning.Contains("already being read", StringComparison.Ordinal));
}
[Fact]
public void CrlfAndAByteOrderMark_ParseTheSameAsPlainText()
{
var import = Read("\ufeffHost prod-db\r\n HostName db.internal\r\n Port 2222\r\n");
var host = import.Hosts.ShouldHaveSingleItem();
host.Alias.ShouldBe("prod-db");
host.Hostname.ShouldBe("db.internal");
host.Port.ShouldBe(2222);
}
[Fact]
public void AnUnusablePort_FallsBackToTwentyTwoAndSaysSo()
{
var host = Read("""
Host prod-db
Port not-a-number
""").Hosts.ShouldHaveSingleItem();
host.Port.ShouldBe(22);
host.Warnings.ShouldContain(warning => warning.Contains("not-a-number", StringComparison.Ordinal));
}
/// <remarks>
/// Recorded, and explicitly not honoured: the SSH layer has no jump hosts. A bastion topology that
/// imported and quietly did not route would be the worst of the three options.
/// </remarks>
[Fact]
public void ProxyJump_IsRecordedAsIntentAndFlagged()
{
var host = Read("""
Host prod-db
HostName db.internal
ProxyJump bastion
""").Hosts.ShouldHaveSingleItem();
host.ProxyJump.ShouldBe("bastion");
var secret = host.ToSecret();
secret.Options.ShouldContain(option => option.Name == "ProxyJump" && option.Value == "bastion");
secret.Notes.ShouldNotBeNull().ShouldContain("does not route");
}
[Fact]
public void ProxyCommand_IsDroppedRatherThanStoredAsIfItWorked()
{
var host = Read("""
Host prod-db
ProxyCommand nc %h %p
""").Hosts.ShouldHaveSingleItem();
host.Options.ShouldNotContain(option => option.Name == "ProxyCommand");
host.Warnings.ShouldContain(warning => warning.Contains("ProxyCommand", StringComparison.Ordinal));
}
/// <remarks>
/// A directive that maps onto a first-class field must not <em>also</em> land in Options, or a host has
/// two places recording its port and one of them will be forgotten on the next edit.
/// </remarks>
[Fact]
public void FirstClassFields_DoNotAlsoAppearAsDirectives()
{
var secret = Read("""
Host prod-db
HostName db.internal
User deploy
Port 2222
ServerAliveInterval 30
""").Hosts.ShouldHaveSingleItem().ToSecret();
secret.Options.Select(option => option.Name).ShouldBe(["ServerAliveInterval"]);
}
[Fact]
public void AnEmptyConfiguration_YieldsNothingAndDoesNotThrow()
{
var import = Read(" \n\n# only a comment\n");
import.Hosts.ShouldBeEmpty();
import.SkippedPatterns.ShouldBeEmpty();
}
private static SshConfigImport Read(string text) =>
SshConfigResolver.Resolve(SshConfigParser.Parse(text));
private static ImportedHost Alias(SshConfigImport import, string alias) =>
import.Hosts.Single(host => string.Equals(host.Alias, alias, StringComparison.Ordinal));
}
@@ -0,0 +1,208 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSubstitute": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"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]"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"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.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.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"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]"
}
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.import": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Domain": "[1.0.0, )"
}
}
}
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The bucket client, without a bucket.
What is covered here is everything that is this project's own reasoning rather than the service's: the
translation between browser paths and object keys, and the refusals the interface promises. What is not
covered is anything that needs an S3 endpoint to answer — those are in docs/manual-checks.md, because a
fake that returned what we expect would only be asserting our own reading of the protocol back at us.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,78 @@
using DodoSSH.Client.ObjectStore;
namespace DodoSSH.Client.ObjectStore.Tests;
/// <summary>
/// Translating between the paths a file browser uses and the keys a bucket has.
/// </summary>
/// <remarks>
/// Small and worth having, because every one of these cases is a way a bucket listing goes subtly wrong
/// rather than loudly: a missing trailing slash lists a sibling directory's contents, and a leading one asks
/// the service for a key beginning with <c>/</c>, which is legal and is never what anybody stored.
/// </remarks>
public sealed class ObjectKeysTests
{
[Theory]
[InlineData("/", "")]
[InlineData("/reports", "reports")]
[InlineData("/reports/2026/q3.csv", "reports/2026/q3.csv")]
public void APathBecomesAKeyWithNoLeadingSlash(string path, string expected)
{
ObjectKeys.ToKey(path).ShouldBe(expected);
}
[Theory]
[InlineData("", "/")]
[InlineData("reports/2026/q3.csv", "/reports/2026/q3.csv")]
public void AKeyBecomesAnAbsolutePath(string key, string expected)
{
ObjectKeys.ToPath(key).ShouldBe(expected);
}
/// <remarks>
/// The one that matters most. Without the trailing slash, listing <c>/reports</c> also returns everything
/// under <c>/reports-archive</c> — a prefix match knows nothing about path segments, and the result is a
/// directory listing with another directory's files in it.
/// </remarks>
[Theory]
[InlineData("/reports", "reports/")]
[InlineData("/reports/", "reports/")]
[InlineData("/reports/2026", "reports/2026/")]
public void APrefixAlwaysEndsInASlash(string path, string expected)
{
ObjectKeys.ToPrefix(path).ShouldBe(expected);
}
/// <remarks>
/// Empty rather than <c>"/"</c>, because a prefix of <c>/</c> matches nothing: no key stored by anything
/// begins with a slash. This is the case that makes the root of a bucket list at all.
/// </remarks>
[Fact]
public void TheRootHasAnEmptyPrefix()
{
ObjectKeys.ToPrefix("/").ShouldBeEmpty();
}
/// <remarks>
/// A common prefix arrives with its trailing slash on, so trimming has to happen before the last segment
/// is taken — otherwise every directory in the listing is named "".
/// </remarks>
[Theory]
[InlineData("reports/2026/", "2026")]
[InlineData("reports/2026/q3.csv", "q3.csv")]
[InlineData("q3.csv", "q3.csv")]
[InlineData("reports/", "reports")]
public void ANameIsTheLastSegment(string key, string expected)
{
ObjectKeys.NameOf(key).ShouldBe(expected);
}
[Theory]
[InlineData("/")]
[InlineData("/reports")]
[InlineData("/reports/2026/q3.csv")]
public void APathSurvivesARoundTrip(string path)
{
ObjectKeys.ToPath(ObjectKeys.ToKey(path)).ShouldBe(path);
}
}
@@ -0,0 +1,122 @@
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.ObjectStore.Tests;
/// <summary>
/// What a bucket needs before it can be stored, and what the factory does with it.
/// </summary>
/// <remarks>
/// Every refusal here exists because the failure it prevents is one whose message names neither the field
/// nor the bucket. A missing region produces an SDK error about resolving an endpoint; a hostname without a
/// scheme produces a URI parse failure; and both arrive at the first listing, long after the typing.
/// </remarks>
public sealed class ObjectStoreSecretTests
{
[Fact]
public void ABucketWithARegion_IsStorable()
{
Bucket().TryValidate(out var reason).ShouldBeTrue(reason);
}
[Fact]
public void ABucketWithAnEndpointAndNoRegion_IsStorable()
{
// The self-hosted case, and the reason Region is nullable rather than defaulted to us-east-1: a
// default would be a guess presented as configuration, and it is wrong for exactly this user.
var store = Bucket() with { Region = null, Endpoint = "https://minio.internal:9000" };
store.TryValidate(out var reason).ShouldBeTrue(reason);
}
[Fact]
public void ABucketWithNeitherARegionNorAnEndpoint_IsRefused()
{
var store = Bucket() with { Region = null, Endpoint = null };
store.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("region");
}
/// <remarks>
/// A bare hostname is what somebody types, and the SDK's own failure for it names a URI rather than this
/// field. Refusing at the editor is the only place the message can be about what was typed.
/// </remarks>
[Theory]
[InlineData("minio.internal:9000")]
[InlineData("/buckets")]
[InlineData("ftp://minio.internal")]
public void AnEndpointThatIsNotAnHttpUrl_IsRefused(string endpoint)
{
var store = Bucket() with { Endpoint = endpoint };
store.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ABucketMissingSomethingItNeeds_IsRefused(string blank)
{
(Bucket() with { Label = blank }).TryValidate(out _).ShouldBeFalse();
(Bucket() with { Bucket = blank }).TryValidate(out _).ShouldBeFalse();
(Bucket() with { AccessKeyId = blank }).TryValidate(out _).ShouldBeFalse();
(Bucket() with { SecretAccessKey = blank }).TryValidate(out _).ShouldBeFalse();
}
[Fact]
public void ABucket_RoundTripsThroughItsCodec()
{
var store = Bucket() with
{
Endpoint = "https://minio.internal:9000",
UsePathStyle = true,
Notes = "the backups bucket",
};
ObjectStoreSecretCodec.TryDecode(ObjectStoreSecretCodec.Encode(store), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.Store.ShouldBe(store);
document.SchemaVersion.ShouldBe(ObjectStoreSecretCodec.CurrentSchemaVersion);
}
/// <remarks>
/// The factory builds a client and contacts nothing, which is why it is synchronous — S3 is
/// request-per-operation and there is no connect step to fail. What it does do is refuse a bucket that
/// could never work, so the failure lands at the button rather than at the first listing.
/// </remarks>
[Fact]
public void TheFactoryRefusesABucketThatCouldNotBeStored()
{
var factory = new S3ObjectStoreFactory();
Should.Throw<ArgumentException>(
() => factory.Open(Bucket() with { Region = null, Endpoint = null }));
}
[Fact]
public void TheFactoryOpensAValidBucketWithoutContactingAnything()
{
var factory = new S3ObjectStoreFactory();
var store = factory.Open(Bucket() with
{
Endpoint = "https://minio.internal:9000",
UsePathStyle = true,
});
store.IsConnected.ShouldBeTrue("nothing is contacted, so there is nothing to be down");
store.HomeDirectory.ShouldBe("/");
}
private static ObjectStoreSecret Bucket() => new()
{
Label = "backups",
Bucket = "dodossh-backups",
AccessKeyId = "AKIAEXAMPLE",
SecretAccessKey = "an example secret access key",
Region = "eu-west-1",
};
}
@@ -0,0 +1,277 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSubstitute": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"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]"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"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.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.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.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"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]"
}
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.objectstore": {
"type": "Project",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, )",
"AWSSDK.S3": "[4.0.101.6, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
"AWSSDK.Core": {
"type": "CentralTransitive",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "CentralTransitive",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
}
@@ -0,0 +1,261 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// What the activity log records when somebody changes something in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// Written against a real unlocked vault rather than a stand-in, because the property under test is where
/// the hook sits: it is in <c>VaultItemRepository</c>, the one generic funnel every kind's writes go
/// through, and a test that called the sink directly would prove nothing about that.
/// </para>
/// <para>
/// The recorder writes on a background task, so every assertion here waits for the entry rather than reading
/// immediately. That is the design and not a testing inconvenience: a save the user is waiting on must not
/// also wait for a log entry to be encrypted.
/// </para>
/// </remarks>
public sealed class ActivityLogTests : IAsyncLifetime
{
private const string Passphrase = "correct horse battery staple";
private const string ServerUrl = "https://dodossh.example";
/// <inheritdoc cref="SessionLifecycleTests" />
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private ClientCacheFactory caches = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"activity-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
caches.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task CreatingAnItem_IsRecordedWithItsNameAndAnActor()
{
await using var session = await UnlockAsync();
var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
var entry = (await WaitForAsync(session, 1)).ShouldHaveSingleItem().Secret;
entry.Operation.ShouldBe(ActivityOperation.Created);
entry.ItemKind.ShouldBe("Host");
entry.ItemId.ShouldBe(entityId);
entry.ItemLabel.ShouldBe("prod-db");
entry.ChangedFields.ShouldBeEmpty("a create changed everything, which is the same as nothing");
// An audit record with no actor is not an audit record. Both halves matter once the vault is shared:
// which account, and which of that account's machines.
entry.ActorUserId.ShouldNotBe(Guid.Empty);
entry.DeviceName.ShouldNotBeNullOrWhiteSpace();
}
/// <remarks>
/// The rule the whole payload is built around. "Username" is what somebody reviewing a keychain needs to
/// see; the account name it was changed to belongs in the item, not in a log that syncs to everybody.
/// </remarks>
[Fact]
public async Task EditingAnItem_RecordsWhichFieldsChangedAndNeverTheirValues()
{
await using var session = await UnlockAsync();
var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
await session.Hosts.UpdateAsync(
session.ActiveVaultId,
entityId,
Host("prod-db") with { Port = 2222, Username = "root" },
Token);
var entries = await WaitForAsync(session, 2);
var edit = entries.Select(item => item.Secret).Single(e => e.Operation is ActivityOperation.Updated);
edit.ChangedFields.ShouldBe("Port, Username");
edit.ChangedFields.ShouldNotContain("2222", Case.Sensitive);
edit.ChangedFields.ShouldNotContain("root", Case.Sensitive);
}
/// <remarks>
/// Two edits are two lines, where the outbox coalesces them into one pending row. The outbox describes
/// what still has to be sent; this describes what somebody did, and those are different questions.
/// </remarks>
[Fact]
public async Task TwoEditsOfOneItem_AreTwoLines()
{
await using var session = await UnlockAsync();
var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
await session.Hosts.UpdateAsync(
session.ActiveVaultId, entityId, Host("prod-db") with { Port = 2222 }, Token);
await session.Hosts.UpdateAsync(
session.ActiveVaultId, entityId, Host("renamed") with { Port = 2222 }, Token);
var entries = await WaitForAsync(session, 3);
entries.Count(item => item.Secret.Operation is ActivityOperation.Updated).ShouldBe(2);
// And the second is measured against the first rather than against what the server last accepted,
// which is why it reports the rename alone and not the port again.
var latest = entries
.Select(item => item.Secret)
.Where(entry => entry.Operation is ActivityOperation.Updated)
.OrderBy(entry => entry.At)
.Last();
latest.ChangedFields.ShouldBe("Name");
}
[Fact]
public async Task DeletingAnItem_IsRecordedWithTheNameItHadWhenItWent()
{
await using var session = await UnlockAsync();
var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
await session.Hosts.DeleteAsync(session.ActiveVaultId, entityId, Token);
var entries = await WaitForAsync(session, 2);
var deletion = entries.Select(e => e.Secret).Single(e => e.Operation is ActivityOperation.Deleted);
// Read before the delete destroyed it. A lookup afterwards resolves to nothing, which is exactly the
// case where the name matters most.
deletion.ItemLabel.ShouldBe("prod-db");
deletion.ItemId.ShouldBe(entityId);
}
/// <remarks>
/// <para>
/// <b>The guard that keeps this feature from being a runaway.</b> The hook lives in the one repository
/// every kind writes through, so without <c>IItemKind.IsAudited</c> a log entry would produce a log
/// entry, without end — and it would do so on a background task, quietly, filling a vault.
/// </para>
/// <para>
/// Asserted by writing entries directly and then waiting long enough for a recursive write to have
/// happened: the count has to stay where it was put.
/// </para>
/// </remarks>
[Fact]
public async Task WritingALogEntry_DoesNotProduceALogEntryAboutIt()
{
await using var session = await UnlockAsync();
await session.ConnectionLog.CreateAsync(session.ActiveVaultId, Connection(), Token);
await session.ConnectionLog.CreateAsync(session.ActiveVaultId, Connection(), Token);
// Long enough for a recursive write to have queued, been drained and stored several times over.
await Task.Delay(TimeSpan.FromMilliseconds(300), Token);
(await session.ActivityLog.ListAsync(session.ActiveVaultId, Token)).Items
.ShouldBeEmpty("a log that logs itself never stops");
(await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token)).Items.Count.ShouldBe(2);
}
/// <remarks>
/// A pin is written by the connect path rather than by a screen, which is precisely the write a hook
/// placed in the view models would have missed — and trusting a host key is one of the more interesting
/// things an audit trail can show.
/// </remarks>
[Fact]
public async Task APinWrittenByTheConnectPath_IsRecordedToo()
{
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(
new Ssh.HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
var entry = (await WaitForAsync(session, 1)).ShouldHaveSingleItem().Secret;
entry.ItemKind.ShouldBe("KnownHostKey");
entry.Operation.ShouldBe(ActivityOperation.Created);
store.Close();
}
/// <summary>Waits for the background recorder to have written at least this many entries.</summary>
/// <remarks>
/// Polled rather than awaited on a signal, because the recorder deliberately exposes none: its whole
/// contract is that the caller does not wait for it. A timeout rather than a loop, so a hook that stopped
/// firing fails as a test rather than as a hang.
/// </remarks>
private static async Task<IReadOnlyList<VaultItem<ActivityLogSecret>>> WaitForAsync(
VaultSession session,
int count)
{
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
while (true)
{
var listing = await session.ActivityLog.ListAsync(session.ActiveVaultId, Token);
if (listing.Items.Count >= count)
{
return listing.Items;
}
if (TimeProvider.System.GetUtcNow() > deadline)
{
listing.Items.Count.ShouldBe(count, "the activity hook stopped firing");
return listing.Items;
}
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
}
}
private async Task<VaultSession> UnlockAsync()
{
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
return outcome.Session!;
}
private static HostSecret Host(string label) =>
new()
{
Label = label,
Hostname = "db.internal",
Port = 22,
Username = "deploy",
};
private static ConnectionLogSecret Connection() => new()
{
HostLabel = "prod-db",
Address = "deploy@db.internal:22",
StartedAt = DateTimeOffset.UnixEpoch,
Duration = TimeSpan.FromMinutes(3),
Outcome = ConnectionOutcome.Closed,
DeviceName = "laptop",
};
}
@@ -0,0 +1,164 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// What a vault stops keeping, and when.
/// </summary>
/// <remarks>
/// Retention is not housekeeping here the way it is for a local log file: these entries sync, so every one
/// kept costs every machine in the vault. That is the price of the decision that made them auditable, and
/// this is what bounds it.
/// </remarks>
public sealed class LogRetentionTests : IAsyncLifetime
{
private const string Passphrase = "correct horse battery staple";
private const string ServerUrl = "https://dodossh.example";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private static readonly DateTimeOffset Now = new(2026, 7, 31, 12, 0, 0, TimeSpan.Zero);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private ClientCacheFactory caches = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"retention-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
caches.Dispose();
return ValueTask.CompletedTask;
}
/// <remarks>
/// Age is read from the entry rather than from the item id. The two are close and not the same: the id
/// records when the entry was <em>written</em>, which for a connection is when it ended — so a shell
/// left open across the retention boundary would be pruned by the wrong clock.
/// </remarks>
[Fact]
public async Task EntriesOlderThanTheAgeLimit_Go()
{
await using var session = await UnlockAsync();
await WriteConnectionAsync(session, Now.AddDays(-91));
await WriteConnectionAsync(session, Now.AddDays(-89));
await WriteConnectionAsync(session, Now.AddHours(-1));
var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token);
result.Connections.ShouldBe(1);
var kept = await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token);
kept.Items.Count.ShouldBe(2);
kept.Items.ShouldAllBe(item => item.Secret.StartedAt > Now.AddDays(-90));
}
/// <remarks>
/// The other limit, and it is the one that binds for somebody who connects all day. Whichever bites
/// first wins: an age alone lets a busy vault grow without bound, and a count alone loses a quiet
/// month's history to one busy afternoon.
/// </remarks>
[Fact]
public async Task EntriesPastTheCountLimit_GoOldestFirst()
{
await using var session = await UnlockAsync();
for (var i = 0; i < 5; i++)
{
await WriteConnectionAsync(session, Now.AddMinutes(-i), $"host-{i}");
}
var result = await LogPruner.PruneAsync(
session, new LogRetention(TimeSpan.FromDays(90), MaxEntries: 3), Now, Token);
result.Connections.ShouldBe(2);
var kept = await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token);
// The newest three survive: entry i was written i minutes ago, so 0, 1 and 2 are the recent ones.
kept.Items.Select(item => item.Secret.HostLabel).Order(StringComparer.Ordinal)
.ShouldBe(["host-0", "host-1", "host-2"]);
}
[Fact]
public async Task PruningTouchesBothLogsAndSaysWhatItRemoved()
{
await using var session = await UnlockAsync();
await WriteConnectionAsync(session, Now.AddDays(-100));
await session.ActivityLog.CreateAsync(
session.ActiveVaultId,
new ActivityLogSecret
{
ItemKind = "Host",
ItemId = Guid.CreateVersion7(),
ItemLabel = "prod-db",
Operation = ActivityOperation.Created,
At = Now.AddDays(-100),
DeviceName = "laptop",
},
Token);
var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token);
result.Connections.ShouldBe(1);
result.Activity.ShouldBe(1);
result.RemovedAnything.ShouldBeTrue();
}
[Fact]
public async Task AVaultInsideItsLimits_LosesNothing()
{
await using var session = await UnlockAsync();
await WriteConnectionAsync(session, Now.AddDays(-1));
var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token);
result.RemovedAnything.ShouldBeFalse();
(await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token)).Items.ShouldHaveSingleItem();
}
private static Task<Guid> WriteConnectionAsync(
VaultSession session,
DateTimeOffset startedAt,
string label = "prod-db") =>
session.ConnectionLog.CreateAsync(
session.ActiveVaultId,
new ConnectionLogSecret
{
HostLabel = label,
Address = "deploy@db.internal:22",
StartedAt = startedAt,
Duration = TimeSpan.FromMinutes(4),
Outcome = ConnectionOutcome.Closed,
DeviceName = "laptop",
},
Token);
private async Task<VaultSession> UnlockAsync()
{
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
return outcome.Session!;
}
}
@@ -239,10 +239,18 @@ public sealed class SessionLifecycleTests : IAsyncLifetime
await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
var transport = new EmptySyncApi();
var report = await session.SyncAsync(transport, Token);
var report = await session.SyncAsync(transport, session.ActiveVaultId, Token);
report.Pushed.ShouldBe(1);
transport.PushCount.ShouldBe(1);
// Two operations for one host: the host, and the activity log entry recording that somebody created
// it. PushedItems is the number that means "the user's own work", and it is one — see
// SyncReport.PushedItems for why the two are counted apart.
report.Pushed.ShouldBe(2);
report.PushedItems.ShouldBe(1);
report.PushedLogEntries.ShouldBe(1);
transport.PushCount.ShouldBe(1, "both go in one batch");
// Zero for the same reason: log entries are not somebody's work waiting to be made safe.
(await session.PendingChangeCountAsync(Token)).ShouldBe(0);
}
@@ -326,12 +326,14 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
"DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -354,6 +356,12 @@
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},
@@ -84,6 +84,41 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
connection.IsConnected.ShouldBeTrue();
}
/// <remarks>
/// <para>
/// <b>The test the key generator exists to pass.</b> Everything else about the hand-written
/// <c>openssh-key-v1</c> container is checked against SSH.NET's own parser, which is the parser this
/// application uses and therefore a fair oracle — but it is still one implementation agreeing with
/// another. This is the one that puts the public half on a real OpenSSH server and authenticates with
/// the private half, which is the only thing anybody actually wants to know.
/// </para>
/// <para>
/// Both algorithms, because they are encoded by entirely different code: Ed25519 goes through the
/// hand-written container, and RSA through the BCL's PKCS#1 export with only the public line
/// hand-encoded. A failure on one says nothing about the other.
/// </para>
/// </remarks>
[Theory]
[InlineData(SshKeyAlgorithm.Ed25519)]
[InlineData(SshKeyAlgorithm.Rsa4096)]
public async Task AKeyThisClientGenerated_AuthenticatesAgainstARealServer(SshKeyAlgorithm algorithm)
{
var generated = SshKeyGenerator.Generate(algorithm, "dodossh@generated");
await fixture.AuthorizeAsync(generated.PublicKeyLine, Token);
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(generated.PrivateKeyArmour), Passphrase: null));
connection.IsConnected.ShouldBeTrue();
// Authenticated is not the same as usable, as above.
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
shell.IsOpen.ShouldBeTrue();
}
private static byte[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
@@ -0,0 +1,124 @@
using System.Text;
using Renci.SshNet;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// Whether a key this client generates is a key anything else recognises.
/// </summary>
/// <remarks>
/// <para>
/// The container is hand-written — see <see cref="OpenSshKeyWriter"/> for why there was no alternative —
/// so the only question that matters is whether a real parser accepts it. SSH.NET's
/// <c>PrivateKeyFile</c> is the parser this application actually hands the key to, which makes it the right
/// oracle: a file that satisfies a test of my own encoding and then fails there would be a test agreeing
/// with the bug.
/// </para>
/// <para>
/// The stronger proof is in <c>KeyAuthenticationTests</c>, which installs a generated public line on a real
/// sshd in a container and connects with it. This class is what fails first and reads clearly when the
/// encoding is wrong.
/// </para>
/// </remarks>
public sealed class SshKeyGeneratorTests
{
[Theory]
[InlineData(SshKeyAlgorithm.Ed25519)]
[InlineData(SshKeyAlgorithm.Rsa4096)]
public void AGeneratedKey_IsOneSshNetCanLoad(SshKeyAlgorithm algorithm)
{
var generated = SshKeyGenerator.Generate(algorithm, "dodossh@test");
using var armour = new MemoryStream(Encoding.ASCII.GetBytes(generated.PrivateKeyArmour));
// No passphrase, which is this writer's whole stated limitation. A throw here is the encoding being
// wrong, not the key.
var parsed = new PrivateKeyFile(armour);
parsed.HostKeyAlgorithms.ShouldNotBeEmpty();
}
/// <remarks>
/// <c>SshKeySecret.TryValidate</c> refuses anything that does not begin <c>-----BEGIN</c>, and refuses
/// anything beginning <c>ssh-</c> outright because that is a pasted <c>.pub</c> file — the mistake
/// people actually make. Asserting it here in those terms rather than by constructing the secret: this
/// project deliberately has no reference to <c>DodoSSH.Client.Domain</c>, which is what keeps the SSH
/// layer testable without a cache or a keychain, and one convenience is not worth spending it.
/// </remarks>
[Theory]
[InlineData(SshKeyAlgorithm.Ed25519)]
[InlineData(SshKeyAlgorithm.Rsa4096)]
public void AGeneratedKey_IsOneTheKeychainWillStore(SshKeyAlgorithm algorithm)
{
var generated = SshKeyGenerator.Generate(algorithm, "dodossh@test");
generated.PrivateKeyArmour.TrimStart().ShouldStartWith("-----BEGIN");
generated.PrivateKeyArmour.TrimStart().ShouldNotStartWith("ssh-");
generated.PublicKeyLine.ShouldStartWith("ssh-", Case.Sensitive);
}
[Fact]
public void TheEd25519PublicLine_IsWhatOpenSshWrites()
{
var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");
var parts = generated.PublicKeyLine.Split(' ');
parts.Length.ShouldBe(3);
parts[0].ShouldBe("ssh-ed25519");
parts[2].ShouldBe("dodossh@test");
// 4-byte length + "ssh-ed25519" + 4-byte length + 32-byte point.
Convert.FromBase64String(parts[1]).Length.ShouldBe(4 + 11 + 4 + 32);
}
[Fact]
public void TheFingerprint_IsTheOneSshKeygenWouldPrint()
{
var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");
generated.Fingerprint.ShouldStartWith("SHA256:");
generated.Fingerprint.ShouldNotEndWith("=", Case.Sensitive);
// Taken over the same blob the public line carries, which is the whole definition of an OpenSSH
// fingerprint. Computing it over anything else — the armour, the file, the raw point — produces a
// string that looks right and never matches what a server reports.
var blob = Convert.FromBase64String(generated.PublicKeyLine.Split(' ')[1]);
generated.Fingerprint.ShouldBe(SshHostKeyFingerprint.Format(blob));
}
/// <remarks>
/// The classic <c>openssh-key-v1</c> bug, and the reason it is worth twenty cases rather than one: the
/// private section is padded to a multiple of eight, and the comment is the last thing in it. So whether
/// the padding is right depends on how long the comment happens to be — a name that lands on a boundary
/// produces a file every parser loads, and one character more produces one that none of them do.
/// </remarks>
[Fact]
public void TheContainerIsPaddedCorrectlyForACommentOfAnyLength()
{
for (var length = 0; length < 20; length++)
{
var comment = new string('c', length);
var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, comment);
using var armour = new MemoryStream(Encoding.ASCII.GetBytes(generated.PrivateKeyArmour));
Should.NotThrow(
() => new PrivateKeyFile(armour),
$"a comment of {length} characters must not change whether the key parses");
}
}
[Fact]
public void TwoGeneratedKeys_AreDifferent()
{
var first = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");
var second = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test");
string.Equals(first.Fingerprint, second.Fingerprint, StringComparison.Ordinal)
.ShouldBeFalse("two generated keys must not share a fingerprint");
string.Equals(first.PrivateKeyArmour, second.PrivateKeyArmour, StringComparison.Ordinal)
.ShouldBeFalse("nor any of their material");
}
}
@@ -71,6 +71,40 @@ public sealed class SshServerFixture : IAsyncLifetime
await container.StartAsync();
}
/// <summary>
/// Adds one <c>authorized_keys</c> line to the account tests authenticate as.
/// </summary>
/// <remarks>
/// <para>
/// The fixture's own key arrives through the image's <c>PUBLIC_KEY</c> variable, which takes one. This
/// is for the case that needs a second: proving a key <em>this client generated</em> authenticates
/// against a real sshd, which is the only test that can establish the hand-written
/// <c>openssh-key-v1</c> encoding is right. A parser accepting the file is weaker — SSH.NET could be
/// forgiving about something OpenSSH is not.
/// </para>
/// <para>
/// sshd re-reads <c>authorized_keys</c> on each authentication attempt, so nothing has to be restarted.
/// </para>
/// </remarks>
public async ValueTask AuthorizeAsync(string publicKeyLine, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(publicKeyLine);
// Single-quoted in the shell and the line is base64 plus an algorithm name and a comment, so there
// is nothing in it a quote could end. Asserted rather than assumed all the same: a silent failure
// here would show up as an authentication error in a test whose subject is the key encoding, which
// is the most misleading way for this to break.
var result = await container!.ExecAsync(
["sh", "-c", $"echo '{publicKeyLine.Trim()}' >> /config/.ssh/authorized_keys"],
cancellationToken);
if (result.ExitCode != 0)
{
throw new InvalidOperationException(
$"Could not install the public key in the container: {result.Stderr}");
}
}
/// <summary>
/// One file-transfer session, opened on first use and shared by every test that wants one.
/// </summary>
@@ -299,6 +299,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -313,6 +314,21 @@
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
}
}
}
@@ -39,6 +39,9 @@ public sealed class AadResourceTypeTests
[InlineData(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet)]
[InlineData(SyncEntityType.PortForward, CryptoSpec.AadResourceType.PortForward)]
[InlineData(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey)]
[InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
[InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
SyncEntityType wire,
CryptoSpec.AadResourceType resource)
@@ -67,6 +70,11 @@ public sealed class AadResourceTypeTests
(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey),
(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential),
(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey),
(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup),
(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet),
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
];
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
@@ -165,6 +173,21 @@ public sealed class AadResourceTypeTests
SyncEntityType.KnownHostKey => KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, generation, version),
SyncEntityType.HostGroup => HostGroupCipher.Seal(
NewGroup(), vaultKey, entityId, generation, version),
SyncEntityType.Snippet => SnippetCipher.Seal(
NewSnippet(), vaultKey, entityId, generation, version),
SyncEntityType.ConnectionLogEntry => ConnectionLogCipher.Seal(
NewConnectionEntry(), vaultKey, entityId, generation, version),
SyncEntityType.ActivityLogEntry => ActivityLogCipher.Seal(
NewActivityEntry(), vaultKey, entityId, generation, version),
SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
NewBucket(), vaultKey, entityId, generation, version),
_ => throw new ArgumentOutOfRangeException(
nameof(wire),
wire,
@@ -292,6 +315,48 @@ public sealed class AadResourceTypeTests
KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
}
private static ConnectionLogSecret NewConnectionEntry() => new()
{
HostLabel = "prod-db",
Address = "deploy@db.internal:22",
HostId = Guid.CreateVersion7(),
StartedAt = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
Duration = TimeSpan.FromMinutes(11),
Outcome = ConnectionOutcome.Closed,
DeviceName = "laptop",
ActorUserId = Guid.CreateVersion7(),
};
private static ActivityLogSecret NewActivityEntry() => new()
{
ItemKind = nameof(SyncEntityType.Host),
ItemId = Guid.CreateVersion7(),
ItemLabel = "prod-db",
Operation = ActivityOperation.Updated,
ChangedFields = "Port, Username",
At = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
DeviceName = "laptop",
ActorUserId = Guid.CreateVersion7(),
};
private static ObjectStoreSecret NewBucket() => new()
{
Label = "backups",
Bucket = "dodossh-backups",
AccessKeyId = "AKIAEXAMPLE",
SecretAccessKey = "an example secret access key",
Region = "eu-west-1",
};
private static HostGroupSecret NewGroup() => new() { Label = "production" };
private static SnippetSecret NewSnippet() => new()
{
Label = "restart the api",
Command = "sudo systemctl restart dodossh-api",
Notes = "checked with the on-call rota first",
};
private static KnownHostSecret NewKnownHost() => new()
{
Host = "db.internal",
@@ -23,6 +23,11 @@ public sealed class ItemKindsTests
SyncEntityType.SshKey,
SyncEntityType.Credential,
SyncEntityType.KnownHostKey,
SyncEntityType.HostGroup,
SyncEntityType.Snippet,
SyncEntityType.ConnectionLogEntry,
SyncEntityType.ActivityLogEntry,
SyncEntityType.ObjectStore,
]);
}
@@ -149,6 +149,45 @@ public sealed class TerminalWorkspaceTests
workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
}
/// <remarks>
/// <para>
/// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
/// frames for a session nothing is listening to, so a send at a dead tab succeeds exactly as loudly as a
/// send at a live one. That is why <c>PasteAsync</c> answers rather than returning void — and why the
/// answer is a <see cref="bool"/> and not an exception, since a tab whose remote hung up an hour ago is
/// still on screen and still clickable.
/// </para>
/// <para>
/// What the renderer does with the frame — bracketed paste, and the Enter deliberately outside it —
/// cannot be reached from here at all. It is JavaScript inside a WebView, and it is in
/// <c>docs/manual-checks.md</c> for that reason.
/// </para>
/// </remarks>
[Fact]
public async Task PastingIntoADeadSessionSaysSoRatherThanDroppingIt()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
(await workspace.PasteAsync(
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
.ShouldBeTrue("the session is live");
(await workspace.PasteAsync(
9999, "uptime", execute: false, TestContext.Current.CancellationToken))
.ShouldBeFalse("this workspace never issued that id");
await workspace.CloseSessionAsync(sessionId);
(await workspace.PasteAsync(
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
.ShouldBeFalse("the tab it names is gone");
}
/// <remarks>
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
@@ -210,6 +210,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -225,6 +226,21 @@
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
@@ -263,7 +263,7 @@ public sealed class FileTransferQueueTests : IDisposable
public async Task ATransferWithNoSessionToRunOn_FailsOnItsOwnRowRatherThanSilently()
{
await using var queue = new FileTransferQueue(
_ => Task.FromException<ISftpSession>(new IOException("the host is not reachable")),
_ => Task.FromException<IRemoteFileStore>(new IOException("the host is not reachable")),
TimeProvider.System);
var finished = await RunToCompletionAsync(
@@ -274,7 +274,7 @@ public sealed class FileTransferQueueTests : IDisposable
}
private FileTransferQueue NewQueue() =>
new(_ => Task.FromResult<ISftpSession>(host), TimeProvider.System);
new(_ => Task.FromResult<IRemoteFileStore>(host), TimeProvider.System);
private string Local(string name) => Path.Combine(workspace, name);
@@ -210,6 +210,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -225,6 +226,21 @@
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
@@ -93,6 +93,9 @@ public sealed class CryptoSpecTests
[InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)]
[InlineData(CryptoSpec.AadResourceType.HostTag, 12)]
[InlineData(CryptoSpec.AadResourceType.HostCredential, 13)]
[InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)]
[InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)]
[InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)]
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
{
((int)type).ShouldBe(expected);
@@ -3,6 +3,7 @@ using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
@@ -84,9 +85,8 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
var host = BuildHost(keyId);
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
var pushed = await laptop.SyncAsync(connection.Sync, Token);
pushed.Pushed.ShouldBe(2);
pushed.NeedsAttention.ShouldBeFalse();
var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
@@ -96,8 +96,13 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
// item type exists to make and the only place it is proved through a real server.
var pin = await OpenAShellAsync(laptop, host);
var trusted = await laptop.SyncAsync(connection.Sync, Token);
trusted.Pushed.ShouldBe(1, "the host key the user approved at the prompt");
var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt");
// And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at
// connect time and never through a screen, which is exactly the write an activity hook placed in the
// view models would have missed — see IActivityLogSink.
trusted.PushedLogEntries.ShouldBe(1);
trusted.NeedsAttention.ShouldBeFalse();
await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);
@@ -158,6 +163,23 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
/// Asserted against what the server hands back, not against the local mirror. With relay off the
/// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise.
/// </remarks>
/// <summary>
/// Two items and two activity entries, through the real server.
/// </summary>
/// <remarks>
/// Creating a key and creating a host are each recorded, and the entries go up in the same batch as the
/// items they are about. <c>PushedItems</c> is the number this assertion was originally written about —
/// the user's own work — and the log entries are counted apart precisely so that number goes on meaning
/// what it meant before there were any.
/// </remarks>
private static void AssertTheKeyAndTheHostWentUpWithTheirLogEntries(SyncReport pushed)
{
pushed.PushedItems.ShouldBe(2);
pushed.PushedLogEntries.ShouldBe(2);
pushed.Pushed.ShouldBe(4);
pushed.NeedsAttention.ShouldBeFalse();
}
private static async Task AssertTheServerCannotSeeTheAddressAsync(
ServerConnection connection,
Guid entityId)
@@ -234,8 +256,13 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
var desktop = await UnlockAsync(desktopCache);
await using var session = desktop;
var pulled = await desktop.SyncAsync(connection.Sync, Token);
pulled.Pulled.ShouldBe(3, "the host, the key and the approved host key, in one pass");
var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass");
// And the three activity entries the first machine wrote about them, which is the claim the log
// exists to make: what somebody did on one machine is readable on another. Once teams land it is an
// administrator reading it rather than the same person, and nothing else about it changes.
pulled.PulledLogEntries.ShouldBe(3);
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
var seen = listing.Items.ShouldHaveSingleItem();
+3 -1
View File
@@ -422,12 +422,14 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
"DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},