Public Access
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
@@ -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])]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,8 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.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.Ssh;
|
||||
@@ -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.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
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.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
@@ -497,6 +499,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": {
|
||||
@@ -505,12 +522,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, )"
|
||||
}
|
||||
},
|
||||
@@ -606,6 +625,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, )",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DodoSSH.Client.App.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;
|
||||
@@ -494,6 +495,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 +1854,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 +2089,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 +2197,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 +2414,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 +2707,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,200 @@
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.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.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
@@ -486,6 +488,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": {
|
||||
@@ -494,12 +511,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, )"
|
||||
}
|
||||
},
|
||||
@@ -595,6 +614,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!;
|
||||
}
|
||||
}
|
||||
@@ -241,8 +241,16 @@ public sealed class SessionLifecycleTests : IAsyncLifetime
|
||||
var transport = new EmptySyncApi();
|
||||
var report = await session.SyncAsync(transport, 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;
|
||||
|
||||
@@ -85,8 +86,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
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();
|
||||
AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);
|
||||
|
||||
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
|
||||
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
|
||||
@@ -97,7 +97,12 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
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");
|
||||
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)
|
||||
@@ -235,7 +257,12 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
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");
|
||||
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