Public Access
Two failures left on the runner, with nothing in common except that both only appear on a machine unlike the one anybody develops on. The runner is Alpine, musl, inside a container, with no fonts installed at all — and that combination is now reproducible locally, which is how these were fixed rather than guessed at. Both are verified by running the suite in it. The layout suite had two causes stacked, and the first hid the second completely. Missing libfontconfig stops libSkiaSharp loading, which the last commit fixed and which then revealed the real one: Avalonia takes its default font family from the platform, and on an image with no fonts there is no answer, so FontManager throws "Default font family name can't be null or empty" inside AppBuilder.SetupUnsafe — before a single test body runs, for all sixty-eight of them, naming none of their subjects. WithInterFont does not prevent it: it registers a collection without nominating a default. HeadlessApp's own comment already claimed it measured "the same Inter font the application registers", which was an intention the code never carried out. Both heads now name it, through FontManagerOptions.DefaultFamilyName. That is worth more than getting CI green: a suite whose entire job is measuring text was taking its metrics from whatever the machine happened to have — Segoe UI here, DejaVu there — and reporting the two as one number. It also means the application uses the font it has been shipping and declining to use since it first referenced the package; almost nothing moves visually, because App.axaml already sets MonoFont on essentially everything that draws. The end-to-end slice was the product being right and the test leaning on an accident. ServerConnection permits an http authority only when it is loopback. Testcontainers reports the host a container can actually be reached at, so running the suite directly gives localhost and passes, while running it inside a container gives the bridge gateway 172.17.0.1 and is refused — correctly, since a client that accepted plaintext metadata from a routable address would be a weakness for everyone who is not a test. Loosening that rule was the wrong repair. The slice now passes configureOidc and says out loud that it accepts plaintext from the Keycloak it started itself. Verified by reproducing the runner rather than approximating it: dotnet/sdk:10.0-alpine, musl-x64, fc-list returning zero, the docker socket mounted so Testcontainers resolves the gateway exactly as it does in CI. The whole solution passes there — 19 suites, 0 failures, 4 skipped — and the end-to-end failure was confirmed causal by reverting only that one file and watching it fail again in the same container. The layout suite also still passes on a Fedora desktop with 595 fonts, so the two agree now. Not verified on Windows, and it should be said plainly rather than left to be discovered: pinning the family changed the measured metrics there too, so a tight layout assertion could have moved. platform-flags.md records that, and corrects the entry the last commit added — "libfontconfig, and nothing else" was true of the container it was tested in and false of the runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
515 lines
23 KiB
C#
515 lines
23 KiB
C#
using System.Text;
|
|
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;
|
|
|
|
namespace DodoSSH.SystemTests;
|
|
|
|
/// <summary>
|
|
/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine,
|
|
/// and open a shell on it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the
|
|
/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real <c>sshd</c> answers at
|
|
/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a
|
|
/// misreading of the protocol can be consistent on both sides and still wrong in production — which is
|
|
/// exactly what this found the first time it ran.
|
|
/// </para>
|
|
/// <para>
|
|
/// One test rather than several, because the steps are not independent: you cannot unlock without having
|
|
/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state
|
|
/// between tests or repeating a minute of setup per assertion.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
|
|
{
|
|
private const string Passphrase = "an end to end passphrase";
|
|
|
|
/// <remarks>
|
|
/// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other
|
|
/// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that
|
|
/// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only
|
|
/// available because nothing is checking, and the difference is a 400 rather than a slow test.
|
|
/// </remarks>
|
|
private static readonly Argon2Profile ServerFloorProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
|
|
|
|
private readonly List<string> directories = [];
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
foreach (var directory in directories.Where(Directory.Exists))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheWholeSlice()
|
|
{
|
|
// The realm file's own account, deliberately — see DevStack.RealmUser. A runtime-minted one hid a
|
|
// sign-in failure that only the committed configuration had.
|
|
var account = DevStack.RealmUser;
|
|
var browser = new ScriptedBrowser(account.Username, account.Password);
|
|
|
|
// The plaintext exemption is stated here rather than inferred from the address, and that is the
|
|
// whole point of stating it. ServerConnection allows an http authority only when it is loopback,
|
|
// which is a sound rule and not one this suite can rely on: Testcontainers reports the host it
|
|
// can actually be reached at, so a developer running the tests directly gets localhost and passes,
|
|
// while the same suite inside a container gets the bridge gateway — 172.17.0.1 — and is refused.
|
|
// That is the product being right. 172.17.0.1 is not loopback, and a client that quietly accepted
|
|
// plaintext metadata from a routable address would be a real weakness for everyone who is not a
|
|
// test. So the test says out loud that it accepts plaintext from the throwaway Keycloak it started
|
|
// itself, and the rule stays as strict as it was for everybody else.
|
|
using var connection = await ServerConnection.SignInAsync(
|
|
stack.ApiBaseUrl,
|
|
browser,
|
|
TimeProvider.System,
|
|
Token,
|
|
configureOidc: options => options with { RequireHttpsMetadata = false });
|
|
|
|
AssertDiscoveredFromTheServer(connection);
|
|
|
|
using var laptopCache = await OpenCacheAsync();
|
|
await EnrollAsync(connection, laptopCache, browser);
|
|
|
|
var laptop = await UnlockAsync(laptopCache);
|
|
await using var laptopSession = laptop;
|
|
|
|
// The key first, because the host binds it. A second item type in the same vault and the same
|
|
// outbox is what makes this a test of the shared write path rather than of hosts: the server picks a
|
|
// table per type, the client picks a cipher per type, and the AAD binds a different resource type
|
|
// into each. All three are hand-kept mappings between enums that do not line up, and a swap between
|
|
// them encrypts, decrypts and stores perfectly on the machine that made it.
|
|
var key = BuildKey();
|
|
var keyId = await laptop.SshKeys.CreateAsync(laptop.ActiveVaultId, key, Token);
|
|
|
|
// Bound to the key, which also makes this host a schema-version-2 payload — so the slice covers a
|
|
// payload written at a version older clients will refuse to edit, through the real server.
|
|
var host = BuildHost(keyId);
|
|
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
|
|
|
|
var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
|
|
AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);
|
|
|
|
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
|
|
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
|
|
|
|
// The shell, and the trust decision it produces. Before the second machine reads the vault, so that
|
|
// what the second machine pulls includes the host key this one approved — which is the claim the whole
|
|
// item type exists to make and the only place it is proved through a real server.
|
|
var pin = await OpenAShellAsync(laptop, host);
|
|
|
|
var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
|
|
trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt");
|
|
|
|
// And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at
|
|
// connect time and never through a screen, which is exactly the write an activity hook placed in the
|
|
// view models would have missed — see IActivityLogSink.
|
|
trusted.PushedLogEntries.ShouldBe(1);
|
|
trusted.NeedsAttention.ShouldBeFalse();
|
|
|
|
await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);
|
|
|
|
await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId, pin);
|
|
|
|
await AssertUnlocksOfflineAsync(laptopCache);
|
|
}
|
|
|
|
// ---- Steps ----
|
|
|
|
/// <remarks>
|
|
/// The user typed one server URL. Everything about the identity provider — the authority, the client
|
|
/// id, the scopes — came back from the server, which is the whole onboarding story.
|
|
/// </remarks>
|
|
private void AssertDiscoveredFromTheServer(ServerConnection connection)
|
|
{
|
|
connection.Configuration.Oidc.Authority.ToString()
|
|
.ShouldStartWith(stack.Authority.ToString());
|
|
|
|
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
|
|
|
|
// Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
|
|
// because it is configuration the server states about itself and nothing else would notice it
|
|
// being wrong.
|
|
connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);
|
|
|
|
connection.Meta.SyncProtocolVersion.ShouldBe(1);
|
|
connection.Meta.CryptoSpecVersion.ShouldBe(1);
|
|
}
|
|
|
|
private async Task EnrollAsync(
|
|
ServerConnection connection,
|
|
ClientCacheFactory caches,
|
|
ScriptedBrowser browser)
|
|
{
|
|
var provisioner = new AccountProvisioner(
|
|
connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
|
|
|
|
var before = await provisioner.RefreshAsync(ServerUrl, Token);
|
|
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
|
|
|
|
var enrolled = await provisioner.EnrollAsync(
|
|
ServerUrl, Passphrase, "e2e-laptop", "Personal", Token);
|
|
|
|
enrolled.Status.ShouldBe(ProvisionStatus.Ready);
|
|
enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
// Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose
|
|
// nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS
|
|
// before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for
|
|
// someone who never enrolled — see ADR 0001 — and it is invisible unless something counts.
|
|
browser.SignInCount.ShouldBe(
|
|
2, "enrollment must obtain an identity-provider signature over the published key");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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)
|
|
{
|
|
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
|
|
|
|
var page = await connection.Sync.SyncPullAsync(
|
|
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token);
|
|
|
|
var change = page.Changes.Single(c => c.EntityId == entityId);
|
|
|
|
change.PlaintextFields.ShouldNotBeNull();
|
|
change.PlaintextFields.RelayEnabled.ShouldBeFalse();
|
|
change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload");
|
|
change.PlaintextFields.Port.ShouldBeNull();
|
|
|
|
// What it does hold is opaque, and it carries its data key as the specification requires.
|
|
change.Payload.ShouldNotBeNull();
|
|
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
|
|
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The relay concession is the host's alone. A key has no address to resolve, so the server is given
|
|
/// nothing at all about it — not even the public-key fingerprint its own schema has a column for, which
|
|
/// it would have accepted. A fingerprint is not secret but it is a stable identifier for a key pair, and
|
|
/// nothing in the product reads that column; see the note on <c>SshKeyKind.Fields</c>.
|
|
/// </remarks>
|
|
private static async Task AssertTheServerLearnsNothingAboutTheKeyAsync(
|
|
ServerConnection connection,
|
|
Guid keyId)
|
|
{
|
|
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
|
|
|
|
var page = await connection.Sync.SyncPullAsync(
|
|
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.SshKey]), Token);
|
|
|
|
// Asked for keys, and got only keys back — so the filter the client relies on is honoured by the
|
|
// real endpoint and not merely by the in-memory one the unit suites use.
|
|
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.SshKey);
|
|
|
|
var change = page.Changes.Single(c => c.EntityId == keyId);
|
|
|
|
change.PlaintextFields.ShouldBeNull(
|
|
"a key gives the server no plaintext columns, so it hydrates to nothing at all");
|
|
|
|
change.Payload.ShouldNotBeNull();
|
|
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
|
|
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Takes the host key presentation the shell step produced, because the point of pinning trust in the
|
|
/// vault is that this machine — which has never spoken to that <c>sshd</c> — already knows the fingerprint
|
|
/// the other one approved.
|
|
/// </remarks>
|
|
private async Task ReadOnASecondMachineAsync(
|
|
ServerConnection connection,
|
|
HostSecret expected,
|
|
Guid entityId,
|
|
SshKeySecret expectedKey,
|
|
Guid keyId,
|
|
HostKeyPresentation pin)
|
|
{
|
|
using var desktopCache = await OpenCacheAsync();
|
|
|
|
var provisioner = new AccountProvisioner(
|
|
connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
|
|
|
|
// Already enrolled, so this only caches what an offline unlock will need.
|
|
(await provisioner.RefreshAsync(ServerUrl, Token)).Status
|
|
.ShouldBe(ProvisionStatus.Ready);
|
|
|
|
var desktop = await UnlockAsync(desktopCache);
|
|
await using var session = desktop;
|
|
|
|
var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
|
|
pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass");
|
|
|
|
// And the three activity entries the first machine wrote about them, which is the claim the log
|
|
// exists to make: what somebody did on one machine is readable on another. Once teams land it is an
|
|
// administrator reading it rather than the same person, and nothing else about it changes.
|
|
pulled.PulledLogEntries.ShouldBe(3);
|
|
|
|
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
|
|
var seen = listing.Items.ShouldHaveSingleItem();
|
|
|
|
seen.EntityId.ShouldBe(entityId);
|
|
seen.HasUnsyncedChanges.ShouldBeFalse();
|
|
|
|
// The decrypted host survived a round trip through a server that could read none of it — including
|
|
// the directives, which merge per name and therefore have to come back in canonical form.
|
|
seen.Secret.ShouldBe(expected);
|
|
|
|
var keys = await desktop.SshKeys.ListAsync(desktop.ActiveVaultId, Token);
|
|
var seenKey = keys.Items.ShouldHaveSingleItem();
|
|
|
|
seenKey.EntityId.ShouldBe(keyId);
|
|
seenKey.HasUnsyncedChanges.ShouldBeFalse();
|
|
|
|
// Including the private key itself, byte for byte and unreformatted, and the passphrase stored with
|
|
// it. This is the whole promise of a shared vault holding a key: a second machine can use it without
|
|
// the key ever having been readable to the thing that carried it.
|
|
seenKey.Secret.ShouldBe(expectedKey);
|
|
|
|
// And the host key trust, which is what stops this machine asking the user to check a fingerprint
|
|
// somebody has already checked. Read through the store the SSH handshake actually asks, so what is
|
|
// proved here is the answer a connection would get and not merely that a row arrived.
|
|
var knownHosts = new VaultKnownHostStore();
|
|
await knownHosts.OpenAsync(desktop, Token);
|
|
|
|
(await knownHosts.FindAsync(pin.Host, pin.Port, pin.Algorithm, Token))
|
|
.ShouldBe(pin.Fingerprint, "trust recorded on one machine has to reach the other");
|
|
|
|
// The algorithm is part of the identity, so a pin must not answer for a key the user never saw.
|
|
(await knownHosts.FindAsync(pin.Host, pin.Port, "ssh-rsa-that-was-never-offered", Token))
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A pin is the item type most likely to be given a plaintext column by mistake — it holds an address the
|
|
/// server may already know for a relay-enabled host, and a fingerprint that is public by nature. Together,
|
|
/// across a vault, they are the list of machines a user reaches. Asserted against the real endpoint's
|
|
/// answer, as the host and the key are.
|
|
/// </remarks>
|
|
private static async Task AssertTheServerLearnsNothingAboutTheTrustedHostAsync(
|
|
ServerConnection connection)
|
|
{
|
|
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
|
|
|
|
var page = await connection.Sync.SyncPullAsync(
|
|
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.KnownHostKey]), Token);
|
|
|
|
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.KnownHostKey);
|
|
|
|
var change = page.Changes.ShouldHaveSingleItem();
|
|
|
|
change.PlaintextFields.ShouldBeNull(
|
|
"which endpoints a user has approved is not something the server is told");
|
|
|
|
change.Payload.ShouldNotBeNull();
|
|
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
|
|
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
|
|
}
|
|
|
|
private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
|
|
{
|
|
// Nothing here touches the network: the salt, the parameters and the wrapped bundle are local.
|
|
var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
|
|
offline.IsUnlocked.ShouldBeTrue(offline.Message);
|
|
await offline.Session!.DisposeAsync();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
|
|
/// caller pins it and retries — which is what the interface does, and the only way to prove the
|
|
/// fingerprint a user would be shown is the one the server actually presented.
|
|
/// </para>
|
|
/// <para>
|
|
/// Through the store that ships, so the pin is sealed under the vault key and queued for the server rather
|
|
/// than kept in a dictionary. That also means the answer the second handshake gets has been through a
|
|
/// real encrypt and decrypt, which is the property an in-memory store cannot exercise.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <returns>The host key that was approved, so a second machine can be asked whether it knows it.</returns>
|
|
private static async Task<HostKeyPresentation> OpenAShellAsync(VaultSession laptop, HostSecret host)
|
|
{
|
|
var knownHosts = new VaultKnownHostStore();
|
|
await knownHosts.OpenAsync(laptop, Token);
|
|
|
|
var factory = new SshNetConnectionFactory(knownHosts);
|
|
|
|
var request = new SshConnectionRequest(
|
|
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
|
|
|
|
HostKeyPresentation? pin = null;
|
|
|
|
try
|
|
{
|
|
await using var first = await factory.ConnectAsync(request, Token);
|
|
Assert.Fail("An unseen host key must not be trusted silently.");
|
|
}
|
|
catch (SshHostKeyUnknownException exception)
|
|
{
|
|
pin = exception.Presentation;
|
|
|
|
pin.Fingerprint.ShouldStartWith("SHA256:");
|
|
await knownHosts.TrustAsync(pin, Token);
|
|
}
|
|
|
|
await using var connection = await factory.ConnectAsync(request, Token);
|
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
|
|
|
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
|
|
|
var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");
|
|
|
|
output.ShouldContain("dodossh-e2e-ok");
|
|
|
|
return pin.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <remarks>
|
|
/// The provisioner takes the URL as a string because it is also the cache's identity — the value an
|
|
/// offline unlock compares against to refuse a cache belonging to another server.
|
|
/// </remarks>
|
|
private string ServerUrl => stack.ApiBaseUrl.ToString();
|
|
|
|
private HostSecret BuildHost(Guid sshKeyId) =>
|
|
new()
|
|
{
|
|
Label = "e2e-target",
|
|
Hostname = stack.SshHostname,
|
|
Port = stack.SshHostPort,
|
|
Username = DevStack.SshUsername,
|
|
Notes = "created by the end-to-end slice",
|
|
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
|
|
SshKeyId = sshKeyId,
|
|
};
|
|
|
|
/// <remarks>
|
|
/// Armour of the right shape around material that is not a key. The shell at the end of this test
|
|
/// authenticates with a password, because what is under test here is the key's journey through the vault
|
|
/// — and a real private key committed to a repository is a real private key on the internet whatever it
|
|
/// was for. That SSH.NET can authenticate with a key delivered this way, as bytes rather than a file, is
|
|
/// established against a real <c>sshd</c> in <c>KeyAuthenticationTests</c>.
|
|
/// </remarks>
|
|
private static SshKeySecret BuildKey() =>
|
|
new()
|
|
{
|
|
Label = "e2e-deploy-key",
|
|
PrivateKeyPem =
|
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----\n",
|
|
Passphrase = "an end to end key passphrase",
|
|
PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 e2e@dodossh",
|
|
Notes = "created by the end-to-end slice",
|
|
};
|
|
|
|
private async Task<ClientCacheFactory> OpenCacheAsync()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
|
|
Directory.CreateDirectory(directory);
|
|
directories.Add(directory);
|
|
|
|
var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile);
|
|
|
|
try
|
|
{
|
|
await factory.MigrateAsync(Token);
|
|
return factory;
|
|
}
|
|
catch
|
|
{
|
|
factory.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task<VaultSession> UnlockAsync(ClientCacheFactory caches)
|
|
{
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
return outcome.Session!;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather
|
|
/// than for a fixed time. The login banner arrives first and its length is not something this test
|
|
/// should have to know.
|
|
/// </remarks>
|
|
private static async Task<string> ReadUntilEchoedAsync(ISshShellSession shell, string marker)
|
|
{
|
|
var text = new StringBuilder();
|
|
var buffer = new byte[8192];
|
|
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
|
deadline.CancelAfter(TimeSpan.FromSeconds(30));
|
|
|
|
while (!deadline.IsCancellationRequested)
|
|
{
|
|
var read = await shell.ReadAsync(buffer, deadline.Token);
|
|
|
|
if (read == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
text.Append(Encoding.UTF8.GetString(buffer, 0, read));
|
|
|
|
if (Occurrences(text.ToString(), marker) >= 2)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return text.ToString();
|
|
}
|
|
|
|
private static int Occurrences(string text, string marker)
|
|
{
|
|
var count = 0;
|
|
var index = 0;
|
|
|
|
while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
index += marker.Length;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
}
|