diff --git a/tasks/implementation/w2-loopback-harness.md b/tasks/implementation/w2-loopback-harness.md index c0bd13c..3874d71 100644 --- a/tasks/implementation/w2-loopback-harness.md +++ b/tasks/implementation/w2-loopback-harness.md @@ -1,7 +1,7 @@ --- id: w2-loopback-harness name: Wave 2 — the loopback harness (in-process client/server test pair) -status: pending +status: completed depends_on: [w2-auth-bridge, w1-test-doubles] scope: moderate risk: medium @@ -70,8 +70,77 @@ the wave-1 doubles work live through the wave-2 stack. ## Notes -> To be filled by implementation agent +- **Transport decision (recorded, in `tests/common/harness.rs` module + docs):** tokio duplex pair (64 KiB/direction) + russh's + `connect_stream`. russh's real codec/kex/transport run over the pair + verbatim; the one gap versus real sockets (no OS-socket semantics) is + covered more cheaply by the real-tool smokes of wave 3+ — TCP + loopback's listener lifecycle and timing flakiness buy nothing the + harness needs. Deterministic, no port, no collision. +- **Placement decision (recorded, same docs):** `tests/common/harness.rs`, + a sibling module of the wave-1 doubles reached with the same + `mod common;` convention — test surface, never a public API export. + Unlike the doubles it is a *vehicle* convention: later waves call + `common::harness::drive` rather than re-deriving a mount/connect pair. +- **russh 0.65 behavior surfaced by the harness (a dispatch-core + input):** each un-implemented server `channel_open_*` hook drops its + `ChannelOpenHandle` → the client observes + `ChannelOpenFailure::AdministrativelyProhibited` — the inherited + fail-closed decline on the current skeleton stack. The harness's + authenticated-session row matches the reason generically + (`Error::ChannelOpenFailure(_)`), so + `w2-dispatch-decline-core`'s reason rows cannot break the vehicle. +- **Scope posture:** the harness is a test-vehicle task — no `src/` + change. A russh-server behavior note rides the row docs only. ## Summary -> To be filled on completion \ No newline at end of file +Landed the loopback harness (`tests/common/harness.rs`, wired through +`tests/common/mod.rs`'s `pub mod harness`), with both pinned decisions +recorded in its module docs: **tokio duplex pair** as the in-process +transport (`russh::client::connect_stream` against the mounted +`SshAdapter::handle`; recorded over TCP loopback for determinism and +no-port portability) and **sibling-of-the-doubles placement** under +`tests/common/` as the crate's contract-test vehicle convention. + +Contents: + +- **`HarnessClientHandler`** — the russh client handler bridging the + `MemHostKeyPolicy` double into `check_server_key`, computing the + decision input ([`PresentedHostKey`]) exactly as the wave-4 dial + wiring will: endpoints labeled `LOOPBACK_HOST` / `LOOPBACK_PORT` + (the no-port marker `0`), the fingerprint on the crate's single + fingerprint path. +- **`client_config`** — the keepalive/wall-clock posture from `Limits` + (interval/max verbatim, inactivity always set). +- **Server-side assemblers** — `serve_config` / `adapter` from the + wave-1 doubles. +- **`drive` → `LoopbackSession`** — the mount-spawn + connect pair, + with clean-disconnect `unmount`. +- **Auth helpers** — `authenticate_publickey` (fixture plumbing + removed, `AuthResult` verbatim) and `remaining_methods` (the + advertisement half of the failure shape). +- **Channel helpers** — `open_session_channel` (open-failure reason + observed in the `Err`), `request_exec` / `request_shell` / + `request_subsystem`, and the message observations: `next_message`, + `await_exit_status`, `await_close`. + +Acceptance proof (`tests/loopback_harness.rs`, 6 rows, every row a +`Contract stamp:`): full in-process connect → fixture-key auth through +`MemIdentityProvider` → authenticated-session observation (host-key +decision log carrying `bravo`'s fingerprint under the loopback labels; +a channel open answered with an observed decline reason); the auth +failure shape with `{publickey}` remaining; the fail-closed host-key +verdict as a clean dial decline; the pinned-rule trust-anchor case; +and the `Limits` → client-config posture row. + +Live-exercised doubles: `MemIdentityProvider`, `MemHostKeySource`, +`MemHostKeyPolicy` (both verdicts). `TestVault` stays on the custody +rows (fixture keys are unencrypted by contract). The session-request +and exit-status/close helpers are exercised by construction this wave +— channel opens don't confirm on the decline-only skeleton; their live +rows arrive with `w2-dispatch-decline-core`. + +Verification: `cargo test` (81 passed across 6 binaries), +`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` — all +green. \ No newline at end of file diff --git a/tests/common/harness.rs b/tests/common/harness.rs new file mode 100644 index 0000000..c70cc0e --- /dev/null +++ b/tests/common/harness.rs @@ -0,0 +1,314 @@ +//! alkssh's loopback harness (`w2-loopback-harness`) — the in-process +//! client/server pair that is *the* contract-test vehicle for the +//! crate: the wave-2 decline matrix, every wave-3 mapping's contract +//! tests, and the client-direction smokes all drive the crate through +//! this module. +//! +//! **Transport decision (recorded, the `w2-loopback-harness` flag):** +//! the server-side [`SshAdapter::handle`](alkssh::SshAdapter::handle) +//! mount runs over a **tokio duplex pair** ([`tokio::io::duplex`], +//! [`DUPLEX_CAPACITY`] bytes each direction), and the client side +//! connects with [`russh::client::connect_stream`] — russh's real +//! codec, kex and transport run over the pair verbatim, so the wire +//! path is fully exercised. The one thing it deliberately does not +//! carry is OS-socket semantics; TCP loopback (`127.0.0.1:0`) was the +//! alternative and is rejected: real-socket nondeterminism (listener +//! lifecycle, loopback timing, accept-loop flakiness under slow CI) +//! buys only what the real-tool smokes of the mapping waves and the +//! e2e wave already cover cheaply. The duplex pair is deterministic, +//! needs no port, and cannot collide — the no-TCP-port-dependency +//! shape the acceptance criteria name. +//! +//! **Placement decision (recorded, the `w2-loopback-harness` +//! counterpart of the wave-1 doubles' location decision):** the +//! harness lives as a sibling module of the wave-1 doubles, under +//! `tests/common/`, reached with the same `mod common;` convention — +//! test surface, test-versioned freely, never a public API export +//! (no re-export from `lib.rs`, no feature gate). Unlike the doubles +//! it is a *vehicle* convention, not just a seam: later waves' +//! contract tests do not re-derive a mount/connect pair; they call +//! [`drive`] (or compose its parts), so a transport revisit stays a +//! one-module change. +//! +//! **Client-side posture (recorded):** the wave-4 dial surface does +//! not exist yet; the harness drives a russh client directly with the +//! doubles: a fixture key ([`authenticate_publickey`]), the +//! [`MemHostKeyPolicy`] decision double bridged into russh's +//! `check_server_key` hook ([`HarnessClientHandler`]), and the +//! keepalive / wall-clock posture from [`alkssh::Limits`] +//! ([`client_config`]). The permissive posture for routine rows is +//! the double's `with_default(true)` — an explicit test choice; the +//! accept-all posture exists only here, never as a crate built-in, +//! and the fail-closed rows construct `with_default(false)`. +//! +//! **Host identity on the loopback (recorded):** a duplex pair has no +//! endpoint, so the presented host key is always labeled with +//! [`LOOPBACK_HOST`] and [`LOOPBACK_PORT`]; host-key policy rows pin +//! rules against those values verbatim. The presented key's +//! fingerprint is the crate's single fingerprint path +//! ([`alkssh::fingerprint`]) — exactly the decision input the wave-4 +//! dial wiring will compute at russh's own hook, so this bridge is +//! that wiring's rehearsal. +//! +//! Helpers stay thin — the russh client API is the vocabulary; these +//! remove boilerplate (config assembly, mount/spawn, fixture-key +//! plumbing, drain loops), never semantics: every observation is a +//! russh-verbatim shape ([`AuthResult`], [`Error`], +//! [`ChannelMsg`]) the rows match on directly. +//! +//! Like the doubles: test functions are *rows* carrying the greppable +//! **`Contract stamp:`** doc markers (see the common module's doc). + +#![allow(dead_code)] + +use std::sync::Arc; + +use alkcall::core::auth::IdentityProvider; +use russh::client::{AuthResult, Handle, Msg}; +use russh::keys::PrivateKeyWithHashAlg; +use russh::{Channel, ChannelMsg, Disconnect, Error, MethodSet}; + +use alkssh::{ + AdapterError, HostKeyPolicy, Limits, PresentedHostKey, ServeConfig, SshAdapter, fingerprint, +}; + +use super::{FixtureKey, MemHostKeyPolicy, MemHostKeySource}; + +/// The host label the loopback presents to the host-key policy — a +/// duplex pair has no endpoint, so every decision is labeled with +/// this constant (see the module docs §Host identity). +pub const LOOPBACK_HOST: &str = "loopback"; + +/// The port label for the loopback's presented host key — `0`, the +/// no-port marker: the transport has no TCP port by design. +pub const LOOPBACK_PORT: u16 = 0; + +/// Each duplex direction's buffer — the `w2-server-mount` wire-smoke +/// precedent's capacity, kept as the harness convention. +pub const DUPLEX_CAPACITY: usize = 65_536; + +/// The harness's russh client config — the keepalive / wall-clock +/// posture from [`Limits`]: `keepalive_interval` / `keepalive_max` +/// mapped verbatim, `inactivity_timeout` carried as always-set (the +/// crate never constructs a session with no wall clock, +/// [`alkssh::Limits`]' in-crate posture extended to the harness's +/// client direction). Everything else stays russh's own default. +pub fn client_config(limits: Limits) -> Arc { + Arc::new(russh::client::Config { + inactivity_timeout: Some(limits.inactivity_timeout), + keepalive_interval: limits.keepalive_interval, + keepalive_max: limits.keepalive_max, + ..Default::default() + }) +} + +/// The harness's russh client handler — the [`MemHostKeyPolicy`] +/// decision double bridged into russh's `check_server_key` hook, with +/// the decision input exactly as the wave-4 dial wiring will shape it +/// ([`PresentedHostKey`] over the crate's fingerprint path). +pub struct HarnessClientHandler { + policy: Arc, +} + +impl HarnessClientHandler { + /// Builds the handler over a policy double — `with_default(true)` + /// for the routine permissive posture, `with_default(false)` (or a + /// pinned rule) for the fail-closed rows. + pub fn new(policy: Arc) -> Self { + Self { policy } + } +} + +impl russh::client::Handler for HarnessClientHandler { + type Error = Error; + + /// The policy decision, verbatim: the presented key's fingerprint + /// is computed on the crate's single fingerprint path, the + /// endpoint labels are the loopback constants, and the policy's + /// `bool` verdict rides russh's hook straight through. + async fn check_server_key( + &mut self, + server_public_key: &russh::keys::PublicKeyOrCertificate, + ) -> Result { + let presented = PresentedHostKey { + host: LOOPBACK_HOST.into(), + port: LOOPBACK_PORT, + fingerprint: fingerprint(&server_public_key.public_key()), + }; + Ok(self.policy.decide(&presented).await) + } +} + +/// Assembles the server side from the wave-1 doubles — a +/// [`ServeConfig`] over the given identity provider and fixture host +/// keys, with the session limits applied. +pub fn serve_config( + provider: Arc, + host_keys: &[&'static FixtureKey], + limits: Limits, +) -> ServeConfig { + let mut serve = ServeConfig::new( + provider, + Arc::new(MemHostKeySource::from_fixtures(host_keys)), + ); + serve.limits = limits; + serve +} + +/// Constructs the adapter from the doubles — [`serve_config`] with the +/// default [`Limits`]; the decrypt-at-construction failure paths are +/// the construction tests' business (`w2-server-mount`), so a harness +/// row's construction failing is a bug, not an observation. +pub async fn adapter( + provider: Arc, + host_keys: &[&'static FixtureKey], +) -> SshAdapter { + SshAdapter::new(serve_config(provider, host_keys, Limits::default())) + .await + .expect("adapter constructs (fixture host keys load)") +} + +/// One driven loopback session: the mounted server task plus the +/// connected client handle and its policy double. +pub struct LoopbackSession { + /// The connected russh client — the helpers' driver. + pub client: Handle, + /// The policy double the connection's host-key decision rode — + /// decision-log inspection for the rows. + pub host_key_policy: Arc, + server_task: tokio::task::JoinHandle>, +} + +impl LoopbackSession { + /// Ends one loopback row cleanly: disconnects the client, waits + /// for the server task, and requires a clean session end on both + /// sides — the harness's every-row termination posture. + pub async fn unmount(self) { + self.client + .disconnect(Disconnect::ByApplication, "row done", "en") + .await + .expect("client disconnects cleanly"); + let outcome = self.server_task.await.expect("server task ends"); + if let Err(err) = outcome { + panic!("session end must be clean, got {err:?}"); + } + } +} + +/// Drives one loopback session: mounts the adapter over a duplex pair +/// (spawned to completion, the assembly's accept-loop shape), connects +/// a harness client against it and returns the pair. The adapter is +/// consumed (the spawned task owns it) — build a fresh one per row; +/// the doubles make that cheap. +/// +/// Fallible at the connect step on purpose: a fail-closed host-key +/// policy verdict is a disposition the rows observe +/// (`Error::UnknownKey`); a routine row's `Err` is its bug. +pub async fn drive( + adapter: SshAdapter, + host_key_policy: Arc, + limits: Limits, +) -> Result { + let (client_io, server_io) = tokio::io::duplex(DUPLEX_CAPACITY); + let server_task = tokio::spawn(async move { adapter.handle(server_io).await }); + + let client = russh::client::connect_stream( + client_config(limits), + client_io, + HarnessClientHandler::new(Arc::clone(&host_key_policy)), + ) + .await?; + + Ok(LoopbackSession { + client, + host_key_policy, + server_task, + }) +} + +/// Authenticates one fixture key — the auth helper: the fixture +/// plumbing (`Arc` + `PrivateKeyWithHashAlg`) removed; the +/// [`AuthResult`] comes back verbatim for the success / failure-shape +/// rows to match on. +pub async fn authenticate_publickey( + client: &mut Handle, + username: &str, + key: &FixtureKey, +) -> AuthResult { + let private = Arc::new(key.private_key()); + client + .authenticate_publickey(username, PrivateKeyWithHashAlg::new(private, None)) + .await + .expect("the auth exchange completes over the wire") +} + +/// The failure shape's advertisement half: the remaining-methods set +/// carried by an [`AuthResult::Failure`] — `None` when the result is +/// a success (the rows' success/failure observation shape). +pub fn remaining_methods(result: &AuthResult) -> Option { + match result { + AuthResult::Failure { + remaining_methods, .. + } => Some(remaining_methods.clone()), + _ => None, + } +} + +/// Opens a session channel — the open helper. Success is a confirmed +/// [`Channel`]; the open-failure shape is the `Err` verbatim +/// ([`Error::ChannelOpenFailure`] carries the SSH decline reason the +/// decline-matrix rows match on). +pub async fn open_session_channel( + client: &Handle, +) -> Result, Error> { + client.channel_open_session().await +} + +/// Sends an exec request over a channel (`want_reply` set — the +/// success/failure shape rides [`ChannelMsg::Success`]/[`ChannelMsg::Failure`] +/// through [`next_message`]). +pub async fn request_exec(channel: &Channel, command: &str) -> Result<(), Error> { + channel.exec(true, command).await +} + +/// Sends a shell request over a channel (`want_reply` set). +pub async fn request_shell(channel: &Channel) -> Result<(), Error> { + channel.request_shell(true).await +} + +/// Sends a subsystem request over a channel (`want_reply` set). +pub async fn request_subsystem(channel: &Channel, name: &str) -> Result<(), Error> { + channel.request_subsystem(true, name).await +} + +/// Observes the next channel message — `None` after the channel has +/// closed (russh's EOF-of-conversation shape). +pub async fn next_message(channel: &mut Channel) -> Option { + channel.wait().await +} + +/// Awaits the channel's exit status — drains interim messages until +/// [`ChannelMsg::ExitStatus`] (the wave-3 derivation contract's +/// observation shape) or the channel's end, which is `None`. +pub async fn await_exit_status(channel: &mut Channel) -> Option { + loop { + match next_message(channel).await { + Some(ChannelMsg::ExitStatus { exit_status }) => return Some(exit_status), + Some(_) => continue, + None => return None, + } + } +} + +/// Awaits the channel's close — drains interim messages until +/// [`ChannelMsg::Close`] (observed: `true`) or the channel's end +/// (`false`). +pub async fn await_close(channel: &mut Channel) -> bool { + loop { + match next_message(channel).await { + Some(ChannelMsg::Close) => return true, + Some(_) => continue, + None => return false, + } + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 45dbe0f..300216f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -48,6 +48,11 @@ #![allow(dead_code)] +/// The loopback harness — *the* contract-test vehicle (its own +/// location/transport decisions live in its module docs: +/// `tests/common/harness.rs`). +pub mod harness; + use std::collections::HashMap; use alkcall::core::auth::{AuthToken, Identity, IdentityProvider}; diff --git a/tests/loopback_harness.rs b/tests/loopback_harness.rs new file mode 100644 index 0000000..7543e48 --- /dev/null +++ b/tests/loopback_harness.rs @@ -0,0 +1,242 @@ +//! The `w2-loopback-harness` acceptance rows — the harness proved +//! live: a full in-process connect → authenticate (fixture key through +//! `MemIdentityProvider`) → authenticated-session observation through +//! the wave-2 stack, plus the observation shapes (auth failure, +//! open-failure reason, host-key verdicts) the later waves' contract +//! tests speak through the same helpers. +//! +//! Every row drives the crate through the harness (`common::harness`) +//! — the recorded transport (tokio duplex pair, no TCP port) and the +//! recorded client posture (fixture keys, `MemHostKeyPolicy`, the +//! `Limits` keepalive/wall-clock posture) are the harness's own +//! decisions, exercised here. +//! +//! The rows are contract stamps: each names the pinning ADR § (the +//! greppable `Contract stamp:` inventory applies). + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use alkcall::core::auth::IdentityProvider; +use common::harness::{ + LOOPBACK_HOST, LOOPBACK_PORT, authenticate_publickey, client_config, drive, + open_session_channel, remaining_methods, serve_config, +}; +use common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity}; +use russh::{Error, MethodSet}; + +use alkssh::{Limits, SshAdapter}; + +fn registered_provider(fingerprints: &[&str]) -> Arc { + let provider = Arc::new(MemIdentityProvider::new()); + for (index, registered) in fingerprints.iter().enumerate() { + provider.register(registered, identity(&format!("user-{index}"), &["ssh"])); + } + provider +} + +fn permissive_policy() -> Arc { + Arc::new(MemHostKeyPolicy::with_default(true)) +} + +/// - *Contract stamp:* ADR-001 (the mount's per-stream edge +/// termination, run to completion over the harness's recorded +/// in-process transport) + ADR-004 decision 1 (the fixture key's +/// fingerprint accepts through `MemIdentityProvider`) + ADR-005 +/// decision 6 (the host key set loads decrypt-at-construction from +/// `MemHostKeySource` — the wave-1 doubles live through the wave-2 +/// stack). +#[tokio::test] +async fn in_process_pair_connects_and_authenticates_end_to_end() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let adapter = SshAdapter::new(serve_config( + provider as Arc, + &[fixture("bravo")], + Limits::default(), + )) + .await + .expect("adapter constructs"); + + let host_key_policy = permissive_policy(); + let mut session = drive(adapter, host_key_policy, Limits::default()) + .await + .expect("the in-process pair connects"); + + let result = authenticate_publickey(&mut session.client, "anyone", fixture("alpha")).await; + assert!( + result.success(), + "the fixture key authenticates: {result:?}" + ); + + let decisions = session.host_key_policy.decisions(); + assert_eq!( + decisions.len(), + 1, + "the host-key decision rode the MemHostKeyPolicy double: {decisions:?}" + ); + assert!(decisions[0].accepted, "the permissive posture accepted"); + assert_eq!(decisions[0].presented.host, LOOPBACK_HOST); + assert_eq!(decisions[0].presented.port, LOOPBACK_PORT); + assert_eq!( + decisions[0].presented.fingerprint, + fixture("bravo").fingerprint, + "the presented host key is the adapter's fixture set, fingerprinted on the crate's path" + ); + + session.unmount().await; +} + +/// - *Contract stamp:* ADR-001 (the authenticated session is live and +/// serving over the mount) + `mapping.md` §Decline matrix (the +/// skeleton's fail-closed decline is observable through the harness +/// as a ChannelOpenFailure reason — the *specific* reason is +/// `w2-dispatch-decline-core`'s row, not this vehicle's). +#[tokio::test] +async fn authenticated_session_serves_and_declines_with_an_observed_reason() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let adapter = SshAdapter::new(serve_config( + provider as Arc, + &[fixture("bravo")], + Limits::default(), + )) + .await + .expect("adapter constructs"); + + let mut session = drive(adapter, permissive_policy(), Limits::default()) + .await + .expect("the in-process pair connects"); + + let result = authenticate_publickey(&mut session.client, "anyone", fixture("alpha")).await; + assert!(result.success(), "authenticated: {result:?}"); + + let opened = open_session_channel(&session.client).await; + assert!( + matches!(opened, Err(Error::ChannelOpenFailure(_))), + "the authenticated session answers a channel open with a decline reason: {opened:?}" + ); + + session.unmount().await; +} + +/// - *Contract stamp:* ADR-004 decision 1 (`None` → reject — the +/// failure shape rides the harness with the advertisement intact) +/// + ADR-004 decision 4 (the remaining-methods set is exactly +/// `{publickey}`). +#[tokio::test] +async fn auth_failure_shape_and_remaining_advertisement_are_observed() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let adapter = SshAdapter::new(serve_config( + provider as Arc, + &[fixture("bravo")], + Limits::default(), + )) + .await + .expect("adapter constructs"); + + let mut session = drive(adapter, permissive_policy(), Limits::default()) + .await + .expect("the in-process pair connects"); + + let result = authenticate_publickey(&mut session.client, "anyone", fixture("delta")).await; + assert!(!result.success(), "the unregistered key must fail"); + let remaining = + remaining_methods(&result).expect("the failure shape carries the remaining-methods set"); + assert_eq!( + remaining, + MethodSet::from(&[russh::MethodKind::PublicKey][..]), + "the publickey-only advertisement survives the decline" + ); + + session.unmount().await; +} + +/// - *Contract stamp:* `auth.md` §Host-key policy (the crate is +/// policy-blind; the fail-closed verdict is the policy's, and the +/// reject disposition rides russh's hook as a hard, clean decline). +#[tokio::test] +async fn fail_closed_host_key_policy_verdict_disconnects_the_dial() { + let provider = registered_provider(&[]); + let adapter = SshAdapter::new(serve_config( + provider as Arc, + &[fixture("bravo")], + Limits::default(), + )) + .await + .expect("adapter constructs"); + + let rejecting_policy = Arc::new(MemHostKeyPolicy::with_default(false)); + let outcome = drive(adapter, rejecting_policy, Limits::default()).await; + + let err = outcome + .err() + .expect("a rejecting policy never connects (the drive errs with the decline)"); + assert!( + matches!(err, Error::UnknownKey), + "the reject disposition is the clean dial decline: {err:?}" + ); +} + +/// - *Contract stamp:* `auth.md` §Host-key policy case 1 (the +/// trust-anchor shape: an exact `(host, fingerprint)` pin accepts +/// with a fail-closed default for everything else) — the policy +/// double's rule vocabulary driven over the wire. +#[tokio::test] +async fn pinned_policy_rule_serves_the_trust_anchor_case() { + let provider = registered_provider(&[fixture("alpha").fingerprint]); + let adapter = SshAdapter::new(serve_config( + provider as Arc, + &[fixture("bravo")], + Limits::default(), + )) + .await + .expect("adapter constructs"); + + let pinned_policy = Arc::new(MemHostKeyPolicy::with_default(false)); + pinned_policy.pin(LOOPBACK_HOST, fixture("bravo").fingerprint); + + let mut session = drive(adapter, pinned_policy, Limits::default()) + .await + .expect("the pin accepts the presented host key"); + assert_eq!( + session.host_key_policy.decisions().len(), + 1, + "one decision, from the pin" + ); + + let result = authenticate_publickey(&mut session.client, "anyone", fixture("alpha")).await; + assert!(result.success(), "the trust-anchored session authenticates"); + + session.unmount().await; +} + +/// - *Contract stamp:* `overview.md` §Bounded-resources policy (the +/// client direction's `Limits` postures map into the harness +/// client config verbatim: keepalive interval/max, wall clock +/// always set). +#[test] +fn client_config_maps_the_limits_postures() { + let limits = Limits { + keepalive_interval: Some(Duration::from_secs(7)), + keepalive_max: 11, + inactivity_timeout: Duration::from_secs(1_337), + ..Limits::default() + }; + let config = client_config(limits); + + assert_eq!(config.keepalive_interval, Some(Duration::from_secs(7))); + assert_eq!(config.keepalive_max, 11); + assert_eq!(config.inactivity_timeout, Some(Duration::from_secs(1_337))); + + let default_config = client_config(Limits::default()); + assert_eq!( + default_config.keepalive_interval, + alkssh::Limits::default().keepalive_interval, + "the default posture rides by default" + ); + assert_eq!( + default_config.inactivity_timeout, + Some(alkssh::Limits::default().inactivity_timeout) + ); +}