mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-23 12:49:04 +00:00
688 lines
27 KiB
Rust
688 lines
27 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use bytes::Bytes;
|
|
use http_body_util::{BodyExt as _, Full};
|
|
use hyper::service::service_fn;
|
|
use hyper::{Request, Response, StatusCode};
|
|
use hyper_util::rt::TokioIo;
|
|
use rcgen::{
|
|
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
|
|
KeyUsagePurpose, SanType,
|
|
};
|
|
use rustfs::connect::{
|
|
CoarseNodeSummary, CredentialStore, DeviceCredential, HeartbeatConfig, HeartbeatSchedule, HeartbeatStatus, IdentityStore,
|
|
spawn_heartbeat_runtime,
|
|
};
|
|
use rustls::RootCertStore;
|
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
|
use rustls::server::WebPkiClientVerifier;
|
|
use serde_json::{Value, json};
|
|
use time::OffsetDateTime;
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::watch;
|
|
use tokio_rustls::TlsAcceptor;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
const ORGANIZATION_UID: &str = "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70";
|
|
const CLUSTER_UID: &str = "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81";
|
|
const DEVICE_UID: &str = "0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92";
|
|
|
|
struct TestPki {
|
|
root_params: CertificateParams,
|
|
root_key: KeyPair,
|
|
root_der: CertificateDer<'static>,
|
|
root_pem: String,
|
|
server_der: CertificateDer<'static>,
|
|
server_key: PrivatePkcs8KeyDer<'static>,
|
|
}
|
|
|
|
impl TestPki {
|
|
fn new() -> Self {
|
|
let now = OffsetDateTime::now_utc();
|
|
let root_key = KeyPair::generate().expect("generate root key");
|
|
let mut root_params = CertificateParams::default();
|
|
root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
|
root_params.not_before = now - time::Duration::days(30);
|
|
root_params.not_after = now + time::Duration::days(30);
|
|
root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::DigitalSignature];
|
|
let root = root_params.self_signed(&root_key).expect("sign root");
|
|
|
|
let server_key = KeyPair::generate().expect("generate server key");
|
|
let mut server_params = CertificateParams::default();
|
|
server_params.not_before = now - time::Duration::hours(1);
|
|
server_params.not_after = now + time::Duration::days(2);
|
|
server_params
|
|
.subject_alt_names
|
|
.push(SanType::DnsName("localhost".try_into().expect("valid DNS name")));
|
|
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
|
|
let server = server_params
|
|
.signed_by(&server_key, &Issuer::from_params(&root_params, &root_key))
|
|
.expect("sign server certificate");
|
|
Self {
|
|
root_params,
|
|
root_key,
|
|
root_der: root.der().clone(),
|
|
root_pem: root.pem(),
|
|
server_der: server.der().clone(),
|
|
server_key: PrivatePkcs8KeyDer::from(server_key.serialize_der()),
|
|
}
|
|
}
|
|
|
|
fn server_config(&self) -> rustls::ServerConfig {
|
|
let mut roots = RootCertStore::empty();
|
|
roots.add(self.root_der.clone()).expect("add client root");
|
|
let verifier = WebPkiClientVerifier::builder(Arc::new(roots))
|
|
.build()
|
|
.expect("client verifier");
|
|
rustls::ServerConfig::builder()
|
|
.with_client_cert_verifier(verifier)
|
|
.with_single_cert(vec![self.server_der.clone()], PrivateKeyDer::Pkcs8(self.server_key.clone_key()))
|
|
.expect("server TLS")
|
|
}
|
|
|
|
fn stores(&self, temp: &tempfile::TempDir) -> (IdentityStore, CredentialStore) {
|
|
let now = OffsetDateTime::now_utc();
|
|
self.stores_with_certificate(temp, now - time::Duration::hours(1), now + time::Duration::hours(23), true)
|
|
}
|
|
|
|
fn stores_with_certificate(
|
|
&self,
|
|
temp: &tempfile::TempDir,
|
|
not_before: OffsetDateTime,
|
|
not_after: OffsetDateTime,
|
|
bind_identity: bool,
|
|
) -> (IdentityStore, CredentialStore) {
|
|
let identity_store = IdentityStore::new(temp.path().join("identity"));
|
|
let identity = identity_store.load_or_create().expect("create identity");
|
|
let private_key = PrivatePkcs8KeyDer::from(identity.to_pkcs8_der().expect("serialize key").to_vec());
|
|
let device_key = if bind_identity {
|
|
KeyPair::from_pkcs8_der_and_sign_algo(&private_key, &rcgen::PKCS_ECDSA_P256_SHA256).expect("device key")
|
|
} else {
|
|
KeyPair::generate().expect("mismatched device key")
|
|
};
|
|
let mut params = CertificateParams::default();
|
|
params.not_before = not_before;
|
|
params.not_after = not_after;
|
|
params.serial_number = Some(vec![1; 16].into());
|
|
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
|
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
|
|
params.distinguished_name = DistinguishedName::new();
|
|
params.distinguished_name.push(DnType::CommonName, DEVICE_UID);
|
|
params.subject_alt_names.push(SanType::URI(
|
|
format!("urn:rustfs:connect:device:{DEVICE_UID}")
|
|
.try_into()
|
|
.expect("device URI"),
|
|
));
|
|
let certificate = params
|
|
.signed_by(&device_key, &Issuer::from_params(&self.root_params, &self.root_key))
|
|
.expect("device certificate");
|
|
let cluster = format!("organizations/{ORGANIZATION_UID}/clusters/{CLUSTER_UID}");
|
|
let credential = DeviceCredential {
|
|
name: format!("{cluster}/clusterDevices/{DEVICE_UID}"),
|
|
uid: DEVICE_UID.to_owned(),
|
|
protocol_version: "v1".to_owned(),
|
|
key_id: format!("x509-{}", "01".repeat(16)),
|
|
certificate_serial: "01".repeat(16),
|
|
certificate: certificate.pem(),
|
|
certificate_chain: certificate.pem(),
|
|
not_before_unix: not_before.unix_timestamp(),
|
|
not_after_unix: not_after.unix_timestamp(),
|
|
};
|
|
let directory = temp.path().join("credential");
|
|
fs::create_dir_all(&directory).expect("credential directory");
|
|
let path = directory.join("device.crt.json");
|
|
fs::write(&path, serde_json::to_vec(&credential).expect("credential JSON")).expect("write credential");
|
|
private_mode(&path);
|
|
(identity_store, CredentialStore::new(directory))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Reply {
|
|
status: StatusCode,
|
|
body: Value,
|
|
retry_after: Option<&'static str>,
|
|
delay: Duration,
|
|
}
|
|
|
|
impl Reply {
|
|
fn ok(time: &str) -> Self {
|
|
Self {
|
|
status: StatusCode::OK,
|
|
body: json!({
|
|
"serverTime": time,
|
|
"acceptedVersion": "v1",
|
|
"capabilityHints": [],
|
|
"futureField": true
|
|
}),
|
|
retry_after: None,
|
|
delay: Duration::ZERO,
|
|
}
|
|
}
|
|
|
|
fn error(status: StatusCode) -> Self {
|
|
Self {
|
|
status,
|
|
body: json!({"details": []}),
|
|
retry_after: None,
|
|
delay: Duration::ZERO,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TestServer {
|
|
endpoint: String,
|
|
seen: Arc<Mutex<Vec<Value>>>,
|
|
task: tokio::task::JoinHandle<()>,
|
|
}
|
|
|
|
impl Drop for TestServer {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
|
|
async fn server(pki: &TestPki, replies: Vec<Reply>) -> TestServer {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind server");
|
|
let address = listener.local_addr().expect("server address");
|
|
let acceptor = TlsAcceptor::from(Arc::new(pki.server_config()));
|
|
let replies = Arc::new(Mutex::new(VecDeque::from(replies)));
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let captured = seen.clone();
|
|
let task = tokio::spawn(async move {
|
|
while let Ok((stream, _)) = listener.accept().await {
|
|
let acceptor = acceptor.clone();
|
|
let replies = replies.clone();
|
|
let seen = captured.clone();
|
|
tokio::spawn(async move {
|
|
let Ok(stream) = acceptor.accept(stream).await else { return };
|
|
let service = service_fn(move |request: Request<hyper::body::Incoming>| {
|
|
let replies = replies.clone();
|
|
let seen = seen.clone();
|
|
async move {
|
|
assert_eq!(request.uri().path(), format!("/agent/clusters/{CLUSTER_UID}/heartbeats"));
|
|
let body = request.into_body().collect().await.expect("request body").to_bytes();
|
|
seen.lock()
|
|
.expect("seen lock")
|
|
.push(serde_json::from_slice(&body).expect("request JSON"));
|
|
let reply = replies
|
|
.lock()
|
|
.expect("reply lock")
|
|
.pop_front()
|
|
.unwrap_or_else(|| Reply::error(StatusCode::SERVICE_UNAVAILABLE));
|
|
if !reply.delay.is_zero() {
|
|
tokio::time::sleep(reply.delay).await;
|
|
}
|
|
let mut builder = Response::builder()
|
|
.status(reply.status)
|
|
.header("content-type", "application/json");
|
|
if let Some(value) = reply.retry_after {
|
|
builder = builder.header("retry-after", value);
|
|
}
|
|
Ok::<_, hyper::Error>(
|
|
builder
|
|
.body(Full::new(Bytes::from(serde_json::to_vec(&reply.body).expect("reply JSON"))))
|
|
.expect("reply"),
|
|
)
|
|
}
|
|
});
|
|
let _ = hyper::server::conn::http1::Builder::new()
|
|
.serve_connection(TokioIo::new(stream), service)
|
|
.await;
|
|
});
|
|
}
|
|
});
|
|
TestServer {
|
|
endpoint: format!("https://localhost:{}/agent/", address.port()),
|
|
seen,
|
|
task,
|
|
}
|
|
}
|
|
|
|
fn config(temp: &tempfile::TempDir, pki: &TestPki, server: &TestServer) -> HeartbeatConfig {
|
|
let (identity_store, credential_store) = pki.stores(temp);
|
|
config_with_stores(temp, pki, server, identity_store, credential_store)
|
|
}
|
|
|
|
fn config_with_stores(
|
|
temp: &tempfile::TempDir,
|
|
pki: &TestPki,
|
|
server: &TestServer,
|
|
identity_store: IdentityStore,
|
|
credential_store: CredentialStore,
|
|
) -> HeartbeatConfig {
|
|
HeartbeatConfig {
|
|
endpoint: server.endpoint.clone(),
|
|
root_ca_pem: pki.root_pem.as_bytes().to_vec(),
|
|
identity_store,
|
|
credential_store,
|
|
state_path: temp.path().join("heartbeat/state.json"),
|
|
schedule: HeartbeatSchedule {
|
|
cadence: Duration::from_millis(40),
|
|
jitter: Duration::ZERO,
|
|
timeout: Duration::from_millis(200),
|
|
initial_backoff: Duration::from_millis(20),
|
|
max_backoff: Duration::from_millis(80),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn rewrite_credential(temp: &tempfile::TempDir, update: impl FnOnce(&mut DeviceCredential)) {
|
|
let path = temp.path().join("credential/device.crt.json");
|
|
let mut credential: DeviceCredential =
|
|
serde_json::from_slice(&fs::read(&path).expect("read credential")).expect("parse credential");
|
|
update(&mut credential);
|
|
fs::write(&path, serde_json::to_vec(&credential).expect("credential JSON")).expect("rewrite credential");
|
|
private_mode(&path);
|
|
}
|
|
|
|
fn summary() -> CoarseNodeSummary {
|
|
CoarseNodeSummary::new(8, 7, 1).expect("node summary")
|
|
}
|
|
|
|
async fn wait_for(
|
|
status: &mut watch::Receiver<HeartbeatStatus>,
|
|
predicate: impl Fn(&HeartbeatStatus) -> bool,
|
|
) -> HeartbeatStatus {
|
|
tokio::time::timeout(Duration::from_secs(3), async {
|
|
loop {
|
|
let current = status.borrow_and_update().clone();
|
|
if predicate(¤t) {
|
|
return current;
|
|
}
|
|
status.changed().await.expect("status channel");
|
|
}
|
|
})
|
|
.await
|
|
.expect("heartbeat status timeout")
|
|
}
|
|
|
|
async fn assert_credential_failure(config: HeartbeatConfig, server: &TestServer, expected: &str) {
|
|
let shutdown = CancellationToken::new();
|
|
let runtime = spawn_heartbeat_runtime(Some(config), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
assert!(matches!(
|
|
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::Failed { .. })).await,
|
|
HeartbeatStatus::Failed { reason } if reason.contains(expected)
|
|
));
|
|
assert!(server.seen.lock().expect("seen lock").is_empty());
|
|
runtime.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connect_config_absent_starts_no_task() {
|
|
let shutdown = CancellationToken::new();
|
|
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let sampled = calls.clone();
|
|
let runtime = spawn_heartbeat_runtime(None, &shutdown, move || {
|
|
sampled.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
summary()
|
|
})
|
|
.expect("absent config");
|
|
|
|
assert!(runtime.is_none());
|
|
tokio::task::yield_now().await;
|
|
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_runtime_is_rejected_without_a_second_task() {
|
|
let pki = TestPki::new();
|
|
let mut reply = Reply::ok("2026-08-22T01:02:03Z");
|
|
reply.delay = Duration::from_secs(5);
|
|
let server = server(&pki, vec![reply]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let config = config(&temp, &pki, &server);
|
|
let runtime = spawn_heartbeat_runtime(Some(config.clone()), &shutdown, summary)
|
|
.expect("first runtime")
|
|
.expect("configured runtime");
|
|
|
|
assert!(matches!(
|
|
spawn_heartbeat_runtime(Some(config), &shutdown, summary),
|
|
Err(rustfs::connect::HeartbeatError::AlreadyRunning)
|
|
));
|
|
runtime.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn dropped_runtime_keeps_the_lock_until_its_task_stops() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![Reply::ok("2026-08-22T01:02:03Z")]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let config = config(&temp, &pki, &server);
|
|
let runtime = spawn_heartbeat_runtime(Some(config.clone()), &shutdown, summary)
|
|
.expect("first runtime")
|
|
.expect("configured runtime");
|
|
|
|
drop(runtime);
|
|
assert!(matches!(
|
|
spawn_heartbeat_runtime(Some(config.clone()), &shutdown, summary),
|
|
Err(rustfs::connect::HeartbeatError::AlreadyRunning)
|
|
));
|
|
|
|
let replacement = tokio::time::timeout(Duration::from_secs(3), async {
|
|
loop {
|
|
match spawn_heartbeat_runtime(Some(config.clone()), &shutdown, summary) {
|
|
Ok(Some(runtime)) => break runtime,
|
|
Err(rustfs::connect::HeartbeatError::AlreadyRunning) => tokio::task::yield_now().await,
|
|
Ok(None) => panic!("configured replacement returned no runtime"),
|
|
Err(error) => panic!("unexpected replacement error: {error}"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("dropped runtime releases its lock after stopping");
|
|
replacement.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn corrupt_persisted_state_is_rejected_before_network_delivery() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![Reply::ok("2026-08-22T01:02:03Z")]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let config = config(&temp, &pki, &server);
|
|
let directory = config.state_path.parent().expect("state directory");
|
|
fs::create_dir_all(directory).expect("create state directory");
|
|
fs::write(
|
|
&config.state_path,
|
|
br#"{"nextSequence":0,"pending":{"protocolVersion":"v1","requestId":"550e8400-e29b-41d4-a716-446655440000","agentVersion":"rustfs-agent/1.0.0-rc.3","capabilities":["heartbeat"],"sequence":0,"clientTime":"2026-08-22T01:02:03Z","coarseNodeSummary":{"total":0,"healthy":0,"degraded":0}}}"#,
|
|
)
|
|
.expect("write corrupt state");
|
|
private_mode(&config.state_path);
|
|
let runtime = spawn_heartbeat_runtime(Some(config), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
|
|
assert!(matches!(
|
|
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::Failed { .. })).await,
|
|
HeartbeatStatus::Failed { reason } if reason.contains("violates the protocol invariants")
|
|
));
|
|
assert!(server.seen.lock().expect("seen lock").is_empty());
|
|
runtime.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_stored_resource_name_is_rejected_before_network_delivery() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let config = config(&temp, &pki, &server);
|
|
rewrite_credential(&temp, |credential| {
|
|
credential.name = format!("organizations/{ORGANIZATION_UID}/clusters/not-a-uuid/clusterDevices/{DEVICE_UID}");
|
|
});
|
|
|
|
assert_credential_failure(config, &server, "wrong device identity").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_stored_protocol_is_rejected_before_network_delivery() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let config = config(&temp, &pki, &server);
|
|
rewrite_credential(&temp, |credential| credential.protocol_version = "v2".to_owned());
|
|
|
|
assert_credential_failure(config, &server, "wrong device identity").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stored_certificate_key_mismatch_is_rejected_before_network_delivery() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let now = OffsetDateTime::now_utc();
|
|
let (identity_store, credential_store) =
|
|
pki.stores_with_certificate(&temp, now - time::Duration::hours(1), now + time::Duration::hours(23), false);
|
|
let config = config_with_stores(&temp, &pki, &server, identity_store, credential_store);
|
|
|
|
assert_credential_failure(config, &server, "different device key").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_stored_certificate_is_rejected_before_network_delivery() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let now = OffsetDateTime::now_utc();
|
|
let (identity_store, credential_store) =
|
|
pki.stores_with_certificate(&temp, now - time::Duration::days(2), now - time::Duration::days(1), true);
|
|
let config = config_with_stores(&temp, &pki, &server, identity_store, credential_store);
|
|
|
|
assert_credential_failure(config, &server, "not currently valid").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sends_only_l0_fields_and_accepts_additive_response_fields() {
|
|
let pki = TestPki::new();
|
|
let server = server(&pki, vec![Reply::ok("2038-01-19T03:14:07Z")]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let runtime = spawn_heartbeat_runtime(Some(config(&temp, &pki, &server)), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
|
|
assert_eq!(
|
|
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::Online { .. })).await,
|
|
HeartbeatStatus::Online {
|
|
server_time: "2038-01-19T03:14:07Z".to_owned()
|
|
}
|
|
);
|
|
runtime.shutdown().await;
|
|
let seen = server.seen.lock().expect("seen lock");
|
|
let request = &seen[0];
|
|
let mut keys = request
|
|
.as_object()
|
|
.expect("heartbeat object")
|
|
.keys()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>();
|
|
keys.sort_unstable();
|
|
assert_eq!(
|
|
keys,
|
|
[
|
|
"agentVersion",
|
|
"capabilities",
|
|
"clientTime",
|
|
"coarseNodeSummary",
|
|
"protocolVersion",
|
|
"requestId",
|
|
"sequence"
|
|
]
|
|
);
|
|
assert_eq!(request["capabilities"], json!(["heartbeat"]));
|
|
assert_eq!(request["coarseNodeSummary"], json!({"total": 8, "healthy": 7, "degraded": 1}));
|
|
assert_ne!(request["clientTime"], "2038-01-19T03:14:07Z");
|
|
assert!(request.get("authorization").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn restart_replays_pending_request_then_advances_sequence() {
|
|
let pki = TestPki::new();
|
|
let first_server = server(&pki, vec![Reply::error(StatusCode::SERVICE_UNAVAILABLE)]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let first_config = config(&temp, &pki, &first_server);
|
|
let runtime = spawn_heartbeat_runtime(Some(first_config.clone()), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::BackingOff { .. })).await;
|
|
runtime.shutdown().await;
|
|
let first = first_server.seen.lock().expect("seen lock")[0].clone();
|
|
drop(first_server);
|
|
|
|
let second_server = server(&pki, vec![Reply::ok("2026-08-22T01:02:03Z"), Reply::ok("2026-08-22T01:02:04Z")]).await;
|
|
let mut second_config = first_config;
|
|
second_config.endpoint = second_server.endpoint.clone();
|
|
let runtime = spawn_heartbeat_runtime(Some(second_config), &shutdown, summary)
|
|
.expect("restart runtime")
|
|
.expect("configured runtime");
|
|
tokio::time::timeout(Duration::from_secs(3), async {
|
|
while second_server.seen.lock().expect("seen lock").len() < 2 {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("two heartbeats");
|
|
runtime.shutdown().await;
|
|
|
|
let seen = second_server.seen.lock().expect("seen lock");
|
|
assert_eq!(seen[0]["requestId"], first["requestId"]);
|
|
assert_eq!(seen[0]["sequence"], first["sequence"]);
|
|
assert_ne!(seen[1]["requestId"], seen[0]["requestId"]);
|
|
assert_eq!(seen[1]["sequence"].as_u64(), seen[0]["sequence"].as_u64().map(|value| value + 1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retry_after_is_respected_with_the_local_upper_bound() {
|
|
let pki = TestPki::new();
|
|
let mut reply = Reply::error(StatusCode::TOO_MANY_REQUESTS);
|
|
reply.retry_after = Some("300");
|
|
let server = server(&pki, vec![reply]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let runtime = spawn_heartbeat_runtime(Some(config(&temp, &pki, &server)), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
assert_eq!(
|
|
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::BackingOff { .. })).await,
|
|
HeartbeatStatus::BackingOff {
|
|
delay: Duration::from_millis(80)
|
|
}
|
|
);
|
|
runtime.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disconnects_use_exponential_backoff_with_a_cap() {
|
|
let pki = TestPki::new();
|
|
let server = server(
|
|
&pki,
|
|
vec![
|
|
Reply::error(StatusCode::SERVICE_UNAVAILABLE),
|
|
Reply::error(StatusCode::SERVICE_UNAVAILABLE),
|
|
Reply::error(StatusCode::SERVICE_UNAVAILABLE),
|
|
],
|
|
)
|
|
.await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let runtime = spawn_heartbeat_runtime(Some(config(&temp, &pki, &server)), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
for delay in [20, 40, 80] {
|
|
assert_eq!(
|
|
wait_for(&mut status, |status| {
|
|
matches!(status, HeartbeatStatus::BackingOff { delay: observed } if *observed == Duration::from_millis(delay))
|
|
})
|
|
.await,
|
|
HeartbeatStatus::BackingOff {
|
|
delay: Duration::from_millis(delay)
|
|
}
|
|
);
|
|
}
|
|
runtime.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn revoked_credential_stops_and_exposes_local_status() {
|
|
let pki = TestPki::new();
|
|
let mut reply = Reply::error(StatusCode::UNAUTHORIZED);
|
|
reply.body = json!({"details": [{"reason": "CREDENTIAL_REVOKED"}]});
|
|
let server = server(&pki, vec![reply]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let runtime = spawn_heartbeat_runtime(Some(config(&temp, &pki, &server)), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
let mut status = runtime.status();
|
|
assert_eq!(
|
|
wait_for(&mut status, |status| matches!(status, HeartbeatStatus::AuthenticationStopped { .. })).await,
|
|
HeartbeatStatus::AuthenticationStopped {
|
|
status: 401,
|
|
reason: Some("CREDENTIAL_REVOKED".to_owned())
|
|
}
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
assert_eq!(server.seen.lock().expect("seen lock").len(), 1);
|
|
runtime.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_cancels_an_in_flight_request() {
|
|
let pki = TestPki::new();
|
|
let mut reply = Reply::ok("2026-08-22T01:02:03Z");
|
|
reply.delay = Duration::from_secs(5);
|
|
let server = server(&pki, vec![reply]).await;
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
let shutdown = CancellationToken::new();
|
|
let runtime = spawn_heartbeat_runtime(Some(config(&temp, &pki, &server)), &shutdown, summary)
|
|
.expect("start runtime")
|
|
.expect("configured runtime");
|
|
tokio::time::timeout(Duration::from_secs(3), async {
|
|
while server.seen.lock().expect("seen lock").is_empty() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("request reached server");
|
|
tokio::time::timeout(Duration::from_millis(250), runtime.shutdown())
|
|
.await
|
|
.expect("cancellable shutdown");
|
|
}
|
|
|
|
#[test]
|
|
fn consumes_the_frozen_heartbeat_fixtures() {
|
|
let registry: Value =
|
|
serde_json::from_str(include_str!("../../protocol/agent/v1/fixtures/fixture-sets.json")).expect("fixture registry");
|
|
let heartbeat = registry["sets"]
|
|
.as_array()
|
|
.expect("fixture sets")
|
|
.iter()
|
|
.find(|set| set["name"] == "heartbeat")
|
|
.expect("heartbeat fixture set");
|
|
assert_eq!(heartbeat["status"], "populated");
|
|
let valid: Value =
|
|
serde_json::from_str(include_str!("../../protocol/agent/v1/fixtures/heartbeat/valid.json")).expect("valid fixture");
|
|
assert_eq!(valid["request"]["protocolVersion"], "v1");
|
|
let overflow: Value =
|
|
serde_json::from_str(include_str!("../../protocol/agent/v1/fixtures/heartbeat/overflow.json")).expect("overflow fixture");
|
|
assert_eq!(overflow["expected"]["httpStatus"], 422);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn private_mode(path: &Path) {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("private mode");
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn private_mode(_path: &Path) {}
|