Public Access
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:
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
+5
-3
@@ -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();
|
||||
|
||||
@@ -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, )"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user