- tests/common/harness.rs: the recorded transport decision (tokio duplex pair + russh connect_stream; recorded over TCP loopback for determinism, no-port portability) and placement decision (sibling of the wave-1 doubles, the crate's contract-test vehicle convention) - the client-side posture from the doubles: MemHostKeyPolicy bridged into russh's check_server_key (HarnessClientHandler, decision input byte-equal to the wave-4 dial wiring's), the Limits keepalive/ inactivity posture (client_config), fixture-key auth helpers - thin helpers: authenticate_publickey + remaining_methods, open_session_channel (open-failure reason observed in the Err), request_exec/shell/subsystem, next_message / await_exit_status / await_close, drive → LoopbackSession with clean unmount - acceptance proof in tests/loopback_harness.rs: 6 contract-stamped rows, incl. the full in-process connect → fixture-key auth → authenticated-session observation (host-key decision log + observed decline reason) and the fail-closed host-key decline Verification: cargo test (81 pass), cargo clippy --all-targets -- -D warnings, cargo fmt --check, taskgraph validate — all green.
243 lines
8.9 KiB
Rust
243 lines
8.9 KiB
Rust
//! 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<MemIdentityProvider> {
|
|
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<MemHostKeyPolicy> {
|
|
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<dyn IdentityProvider>,
|
|
&[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<dyn IdentityProvider>,
|
|
&[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<dyn IdentityProvider>,
|
|
&[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<dyn IdentityProvider>,
|
|
&[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<dyn IdentityProvider>,
|
|
&[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)
|
|
);
|
|
}
|