use super::*;
#[tokio::test]
async fn test_handshake_read_error_preserves_retry_handle() {
use pubky_testnet::pubky_homeserver::{AppContext, ConfigToml, HomeserverApp, MockDataDir};
use std::{net::TcpListener, sync::Arc, time::Duration};
let testnet = build_testnet().await;
let http = TcpListener::bind("127.0.0.1:0").unwrap();
let tls = TcpListener::bind("127.0.0.1:0").unwrap();
let sockets = [http.local_addr().unwrap(), tls.local_addr().unwrap()];
let mut config = ConfigToml::minimal_test_config();
if std::env::var_os("TEST_PUBKY_CONNECTION_STRING").is_none() {
config.general.database_url =
Some(DockerPostgres::shared().await.connection_string().unwrap());
}
config.drive.icann_listen_socket = sockets[0];
config.drive.pubky_listen_socket = sockets[1];
config.pkdns.dht_bootstrap_nodes = Some(testnet.testnet.dht_bootstrap_nodes());
let context = AppContext::read_from(MockDataDir::new(config, None).unwrap())
.await
.unwrap();
drop((http, tls));
let server = HomeserverApp::start(context.clone()).await.unwrap();
let client = Pubky::with_client(
testnet
.client_builder()
.request_timeout(Duration::from_secs(5))
.build()
.unwrap(),
);
let setup =
InProgressHandshakeSetup::on_server(testnet, server.public_key(), client.clone(), client)
.await;
let responder = match advance_handshake(setup.responder_handshake).await.unwrap() {
HandshakeProgress::Pending(handshake) => handshake,
HandshakeProgress::Complete(_) => panic!("an absent first packet must remain Pending"),
};
let before = responder.serialize().unwrap();
let before_config = Arc::clone(responder.config());
let mut rejected_write = restore_encrypted_link_handshake_from_config(
Arc::clone(setup.initiator_handshake.config()),
setup.responder_session.info().public_key(),
setup.initiator_handshake.snapshot().unwrap(),
)
.await
.unwrap();
rejected_write.set_max_recovery_attempts(0);
let initiator = match advance_handshake(setup.initiator_handshake).await.unwrap() {
HandshakeProgress::Pending(handshake) => handshake,
HandshakeProgress::Complete(_) => panic!("the first XX packet cannot complete the link"),
};
drop(server);
tokio::time::timeout(Duration::from_secs(10), async {
for socket in sockets {
while tokio::net::TcpStream::connect(socket).await.is_ok() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
})
.await
.unwrap();
let error = advance_handshake(responder).await.err().unwrap();
assert!(matches!(error.error(), PaykitError::Transport { .. }));
assert_eq!(error.to_string(), error.error().to_string());
let debug = format!("{error:?}");
assert!(!debug.contains(&format!(
"{:?}",
before_config.pubky_root_keypair.secret_key()
)));
assert!(!debug.contains(&before_config.read_path));
let (cause, retry) = error.into_parts();
assert!(matches!(cause, PaykitError::Transport { .. }));
let retry = retry.expect("an unchanged read failure retains its handshake");
assert_eq!(retry.serialize().unwrap(), before);
assert!(Arc::ptr_eq(retry.config(), &before_config));
let (cause, retry_write) = advance_handshake(rejected_write)
.await
.err()
.unwrap()
.into_parts();
assert!(matches!(cause, PaykitError::Transport { .. }));
assert!(
retry_write.is_none(),
"write failure must not expose mutated state"
);
let _server = HomeserverApp::start(context).await.unwrap();
tokio::join!(
drive_handshake_to_completion(initiator),
drive_handshake_to_completion(*retry),
);
}
#[tokio::test]
async fn test_handshake_paths_isolate_copied_noise_key() {
let setup = TestSetup::new().await;
let local_noise_secret = derive_paykit_noise_secret_key(&[1; 32]);
let remote_noise_public = derive_paykit_noise_public_key(&[2; 32]);
let bob = Keypair::from_secret(&[3; 32]).public_key();
let other_identity = Keypair::from_secret(&[4; 32]).public_key();
let outbox_client = setup._testnet.sdk().unwrap();
let handshake = |remote_identity: &PublicKey| {
initiate_encrypted_link(
setup.session.clone(),
local_noise_secret,
remote_identity,
&remote_noise_public,
EncryptedLinkRecoveryContext::default(),
outbox_client.clone(),
)
.unwrap()
};
let bob_handshake = handshake(&bob);
let other_handshake = handshake(&other_identity);
assert_ne!(
bob_handshake.config().write_path,
other_handshake.config().write_path,
);
assert_ne!(
bob_handshake.config().read_path,
other_handshake.config().read_path,
);
let bob_slot = format!("{}/0", bob_handshake.config().write_path);
setup
.session
.storage()
.put(&bob_slot, "bob-slot")
.await
.unwrap();
let other_slot = format!("{}/0", other_handshake.config().write_path);
let _ = advance_handshake(other_handshake).await.unwrap();
assert_eq!(
setup
.session
.storage()
.get(&bob_slot)
.await
.unwrap()
.text()
.await
.unwrap(),
"bob-slot",
);
assert_eq!(
clear_encrypted_link_outbox(
&setup.session,
&local_noise_secret,
&other_identity,
&remote_noise_public,
&EncryptedLinkRecoveryContext::default(),
)
.await
.unwrap(),
1,
);
assert_eq!(
setup
.session
.storage()
.get(&bob_slot)
.await
.unwrap()
.text()
.await
.unwrap(),
"bob-slot",
);
assert!(setup.session.storage().get(&other_slot).await.is_err());
}
#[tokio::test]
async fn test_recovery_markers_isolate_copied_noise_key() {
let setup = TestSetup::new().await;
let local_noise_secret = derive_paykit_noise_secret_key(&[1; 32]);
let local_noise_public = derive_paykit_noise_public_key(&[1; 32]);
let remote_noise_secret = derive_paykit_noise_secret_key(&[2; 32]);
let remote_noise_public = derive_paykit_noise_public_key(&[2; 32]);
let bob = Keypair::from_secret(&[3; 32]).public_key();
let other_identity = Keypair::from_secret(&[4; 32]).public_key();
let bob_marker = EncryptedLinkRecoveryMarker::new(
"650e8400-e29b-41d4-a716-446655440000",
"2026-06-03T12:00:00Z",
)
.unwrap();
let other_marker = EncryptedLinkRecoveryMarker::new(
"650e8400-e29b-41d4-a716-446655440001",
"2026-06-03T12:00:00Z",
)
.unwrap();
for (identity, marker) in [(&bob, &bob_marker), (&other_identity, &other_marker)] {
publish_encrypted_link_recovery_marker(
&setup.session,
&local_noise_secret,
identity,
&remote_noise_public,
marker,
)
.await
.unwrap();
}
for (identity, expected) in [
(&bob, Some(bob_marker.clone())),
(&other_identity, Some(other_marker)),
] {
let fetched = fetch_encrypted_link_recovery_marker(
&setup.public_storage,
&remote_noise_secret,
identity,
&setup.public_key,
&local_noise_public,
)
.await
.unwrap();
assert_eq!(fetched, expected);
}
remove_encrypted_link_recovery_marker(
&setup.session,
&local_noise_secret,
&other_identity,
&remote_noise_public,
)
.await
.unwrap();
for (identity, expected) in [(&bob, Some(bob_marker)), (&other_identity, None)] {
let fetched = fetch_encrypted_link_recovery_marker(
&setup.public_storage,
&remote_noise_secret,
identity,
&setup.public_key,
&local_noise_public,
)
.await
.unwrap();
assert_eq!(fetched, expected);
}
}
#[tokio::test]
async fn test_encrypted_link_restore_rejects_other_identity_paths() {
let setup = PrivateTestSetup::new().await;
let snapshot = setup.sender_link.snapshot().unwrap();
let config = setup.sender_link.config();
let other_identity = Keypair::from_secret(&[5; 32]).public_key();
let other_handshake = initiate_encrypted_link(
setup.sender_session.clone(),
config.pubky_root_keypair.secret_key(),
&other_identity,
snapshot.remote_noise_public_key(),
EncryptedLinkRecoveryContext::default(),
config.outbox_client.clone(),
)
.unwrap();
let recipient = snapshot.recipient().clone();
let result =
restore_encrypted_link_from_config(other_handshake.config().clone(), &recipient, snapshot)
.await;
assert!(matches!(result, Err(PaykitError::Validation(message))
if message == "Noise config paths do not match Pubky identity pair"));
}
#[tokio::test]
async fn test_encrypted_link_restore_rejects_different_noise_key() {
let setup = PrivateTestSetup::new().await;
let snapshot = setup.sender_link.snapshot().unwrap();
let recipient = snapshot.recipient().clone();
let different_secret = derive_paykit_noise_secret_key(&[99; 32]);
let outbox_client = setup.sender_link.config().outbox_client.clone();
let different_handshake = initiate_encrypted_link(
setup.sender_session.clone(),
different_secret,
&recipient,
snapshot.remote_noise_public_key(),
snapshot.recovery_context().clone(),
outbox_client.clone(),
)
.unwrap();
let from_config = restore_encrypted_link_from_config(
different_handshake.config().clone(),
&recipient,
EncryptedLinkSnapshot::deserialize(&snapshot.serialize()).unwrap(),
)
.await;
let from_secret = restore_encrypted_link(
setup.sender_session,
different_secret,
&recipient,
outbox_client,
snapshot,
)
.await;
for result in [from_config, from_secret] {
assert!(matches!(result, Err(PaykitError::Validation(message))
if message == "Noise config key does not match snapshot static key"));
}
}
#[tokio::test]
async fn test_handshake_restore_rejects_different_noise_key() {
let setup = InProgressHandshakeSetup::new().await;
let snapshot = setup.initiator_handshake.snapshot().unwrap();
let recipient = snapshot.recipient().clone();
let different_secret = derive_paykit_noise_secret_key(&[99; 32]);
let outbox_client = setup.initiator_handshake.config().outbox_client.clone();
let different_handshake = initiate_encrypted_link(
setup.initiator_session.clone(),
different_secret,
&recipient,
snapshot.remote_noise_public_key(),
snapshot.recovery_context().clone(),
outbox_client.clone(),
)
.unwrap();
let from_config = restore_encrypted_link_handshake_from_config(
different_handshake.config().clone(),
&recipient,
EncryptedLinkHandshakeSnapshot::deserialize(&snapshot.serialize()).unwrap(),
)
.await;
let from_secret = restore_encrypted_link_handshake(
setup.initiator_session,
different_secret,
&recipient,
outbox_client,
snapshot,
)
.await;
for result in [from_config, from_secret] {
assert!(matches!(result, Err(PaykitError::Validation(message))
if message == "Noise config key does not match snapshot static key"));
}
}
#[tokio::test]
async fn test_handshake_snapshot_serialize_roundtrip() {
let InProgressHandshakeSetup {
_testnet,
initiator_session,
responder_session,
initiator_handshake,
responder_handshake: _responder_handshake,
} = InProgressHandshakeSetup::new().await;
let snapshot = initiator_handshake.snapshot().unwrap();
let bytes = snapshot.serialize();
let restored_snapshot = EncryptedLinkHandshakeSnapshot::deserialize(&bytes).unwrap();
assert_eq!(
restored_snapshot.recovery_context(),
snapshot.recovery_context()
);
let mut invalid = bytes.clone();
*invalid.last_mut().unwrap() = b'x';
assert!(matches!(
EncryptedLinkHandshakeSnapshot::deserialize(&invalid),
Err(PaykitError::InvalidData { .. })
));
assert_eq!(
restored_snapshot.recipient(),
snapshot.recipient(),
"recipient public key should survive serialize/deserialize"
);
assert_eq!(
restored_snapshot.remote_noise_public_key(),
snapshot.remote_noise_public_key(),
"remote Noise public key should survive serialize/deserialize"
);
let bytes2 = restored_snapshot.serialize();
assert_eq!(
bytes, bytes2,
"double round-trip should produce identical bytes"
);
initiator_session.signout().await.unwrap();
responder_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_handshake_restore_and_complete() {
let InProgressHandshakeSetup {
_testnet,
initiator_session,
responder_session,
mut initiator_handshake,
responder_handshake,
} = InProgressHandshakeSetup::new().await;
for (handshake, reads) in [(&initiator_handshake, false), (&responder_handshake, true)] {
let config = handshake.config();
let snapshot = handshake.snapshot().unwrap();
let path = snapshot
.next_handshake_read_path(
config.local_session.info().public_key(),
&config.pubky_root_keypair.secret_key(),
)
.unwrap();
assert_eq!(path.is_some(), reads);
if let Some(path) = path {
assert!(!config
.outbox_client
.public_storage()
.exists(path)
.await
.unwrap());
}
assert!(matches!(
snapshot.next_handshake_read_path(config.local_session.info().public_key(), &[0; 32],),
Err(PaykitError::Validation(_))
));
}
initiator_handshake.set_max_recovery_attempts(99);
let initiator_handshake = match advance_handshake(initiator_handshake).await.unwrap() {
HandshakeProgress::Pending(h) => h,
HandshakeProgress::Complete(_) => {
panic!("initiator handshake unexpectedly completed in one step")
}
};
let config = responder_handshake.config();
let path = responder_handshake
.snapshot()
.unwrap()
.next_handshake_read_path(
config.local_session.info().public_key(),
&config.pubky_root_keypair.secret_key(),
)
.unwrap()
.unwrap();
assert!(config
.outbox_client
.public_storage()
.exists(path)
.await
.unwrap());
let responder_handshake = match advance_handshake(responder_handshake).await.unwrap() {
HandshakeProgress::Pending(h) => h,
HandshakeProgress::Complete(_) => {
panic!("responder handshake unexpectedly completed in one step")
}
};
let initiator_config = initiator_handshake.config().clone();
let responder_config = responder_handshake.config().clone();
let initiator_snapshot_bytes = initiator_handshake.serialize().unwrap();
let responder_snapshot_bytes = responder_handshake.serialize().unwrap();
let initiator_snapshot =
EncryptedLinkHandshakeSnapshot::deserialize(&initiator_snapshot_bytes).unwrap();
let responder_snapshot =
EncryptedLinkHandshakeSnapshot::deserialize(&responder_snapshot_bytes).unwrap();
let initiator_remote = initiator_snapshot.recipient().clone();
let responder_remote = responder_snapshot.recipient().clone();
let restored_initiator = restore_encrypted_link_handshake(
initiator_session.clone(),
initiator_config.pubky_root_keypair.secret_key(),
&initiator_remote,
initiator_config.outbox_client.clone(),
initiator_snapshot,
)
.await
.unwrap();
let restored_responder = restore_encrypted_link_handshake_from_config(
responder_config,
&responder_remote,
responder_snapshot,
)
.await
.unwrap();
assert_eq!(restored_initiator.recovery_attempts_for_test(), 0);
assert_eq!(
restored_initiator.max_recovery_attempts_for_test(),
DEFAULT_MAX_RECOVERY_ATTEMPTS
);
let HandshakeProgress::Complete(mut initiator_link) =
advance_handshake(restored_initiator).await.unwrap()
else {
panic!("initiator must complete when it sends the final handshake message");
};
let HandshakeProgress::Complete(mut responder_link) =
advance_handshake(restored_responder).await.unwrap()
else {
panic!("responder must complete when it receives the final handshake message");
};
let mut payment_endpoints = HashMap::new();
payment_endpoints.insert(
PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
PaymentEndpointPayload::new("lnrestored..."),
);
set_private_payment_list(
&mut initiator_link,
&private_payment_list(&payment_endpoints),
)
.await
.unwrap();
let received = receive_latest_private_payment_list_for_test(&mut responder_link)
.await
.unwrap();
assert_eq!(received.payment_endpoints.len(), 1);
assert_eq!(
received
.payment_endpoints
.get(&PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap()),
Some(&PaymentEndpointPayload::new("lnrestored..."))
);
close_encrypted_link(initiator_link).await.unwrap();
close_encrypted_link(responder_link).await.unwrap();
initiator_session.signout().await.unwrap();
responder_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_handshake_restore_rejects_mismatched_remote_pubkey() {
let InProgressHandshakeSetup {
_testnet,
initiator_session,
responder_session,
initiator_handshake,
responder_handshake: _responder_handshake,
} = InProgressHandshakeSetup::new().await;
let snapshot = initiator_handshake.snapshot().unwrap();
let config = initiator_handshake.config().clone();
let wrong_remote = initiator_session.info().public_key().clone();
let result =
restore_encrypted_link_handshake_from_config(config, &wrong_remote, snapshot).await;
let err = match result {
Ok(_) => panic!("restore should reject mismatched remote pubkey"),
Err(err) => err,
};
assert!(
matches!(err, PaykitError::Validation(ref msg) if msg.contains("does not match snapshot recipient")),
"expected Validation mismatch error, got: {err}"
);
initiator_session.signout().await.unwrap();
responder_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_handshake_snapshot_rejects_transport_phase_snapshot() {
let setup = PrivateTestSetup::new().await;
let transport_bytes = setup.sender_link.serialize().unwrap();
let mut checkpoint = vec![1];
checkpoint.extend_from_slice(&(transport_bytes.len() as u16).to_be_bytes());
checkpoint.extend_from_slice(&transport_bytes);
checkpoint.extend_from_slice(&0_u16.to_be_bytes());
assert!(matches!(
EncryptedLinkHandshakeSnapshot::deserialize(&checkpoint),
Err(PaykitError::InvalidData { .. })
));
close_encrypted_link(setup.sender_link).await.unwrap();
close_encrypted_link(setup.receiver_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_handshake_snapshot_deserialize_rejects_garbage() {
let result = EncryptedLinkHandshakeSnapshot::deserialize(&[0u8; 10]);
assert!(result.is_err(), "deserializing garbage should fail");
let err = result.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { .. }),
"expected InvalidData error, got: {err}"
);
}
#[tokio::test]
async fn test_handshake_snapshot_deserialize_rejects_wrong_length() {
let result = EncryptedLinkHandshakeSnapshot::deserialize(&[0u8; 189]);
assert!(
matches!(result, Err(PaykitError::InvalidData { .. })),
"snapshots with the wrong serialized length should fail"
);
}
fn transport_snapshot_state_with_nonces(
sending_nonce: u64,
receiving_nonce: u64,
) -> pubky_noise::serializer::PubkyNoiseSessionState {
pubky_noise::serializer::PubkyNoiseSessionState {
version: pubky_noise::serializer::SESSION_STATE_VERSION,
phase: pubky_noise::snow_crypto::NoisePhase::Transport,
pattern: pubky_noise::snow_crypto::HandshakePattern::PatternXX,
initiator: true,
ephemeral_secret: [1; 32],
static_secret: Some([2; 32]),
counter: 3,
noise_step: pubky_noise::snow_crypto::NoiseStep::Final,
sub_step_index: 0,
handshake_hash: Some([3; 32]),
link_id: Some([4; 32]),
sending_nonce,
receiving_nonce,
write_counter: 3,
read_counter: 3,
endpoint_pubkey: Keypair::random().public_key().as_inner().to_bytes(),
handshake_messages: vec![vec![5; 96]],
}
}
#[tokio::test]
async fn test_encrypted_link_snapshot_serialize_roundtrip() {
let mut setup = PrivateTestSetup::new().await;
let mut payment_endpoints = HashMap::new();
payment_endpoints.insert(
PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
PaymentEndpointPayload::new("ln..."),
);
set_private_payment_list(
&mut setup.sender_link,
&private_payment_list(&payment_endpoints),
)
.await
.unwrap();
let snapshot = setup.sender_link.snapshot().unwrap();
let bytes = snapshot.serialize();
let restored_snapshot = EncryptedLinkSnapshot::deserialize(&bytes).unwrap();
assert_eq!(
restored_snapshot.recipient(),
snapshot.recipient(),
"recipient public key should survive serialize/deserialize"
);
assert_eq!(
restored_snapshot.remote_noise_public_key(),
snapshot.remote_noise_public_key(),
"remote Noise public key should survive serialize/deserialize"
);
let bytes2 = restored_snapshot.serialize();
assert_eq!(
bytes, bytes2,
"double round-trip should produce identical bytes"
);
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_encrypted_link_restore_and_continue() {
let mut setup = PrivateTestSetup::new().await;
let mut payment_endpoints_v1 = HashMap::new();
payment_endpoints_v1.insert(
PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
PaymentEndpointPayload::new("lnv1..."),
);
set_private_payment_list(
&mut setup.sender_link,
&private_payment_list(&payment_endpoints_v1),
)
.await
.unwrap();
let received_v1 = receive_latest_private_payment_list_for_test(&mut setup.receiver_link)
.await
.unwrap();
assert_eq!(received_v1.payment_endpoints.len(), 1);
let sender_snapshot = setup.sender_link.snapshot().unwrap();
let receiver_snapshot = setup.receiver_link.snapshot().unwrap();
let sender_bytes = sender_snapshot.serialize();
let receiver_bytes = receiver_snapshot.serialize();
let sender_state = EncryptedLinkSnapshot::deserialize(&sender_bytes).unwrap();
let receiver_state = EncryptedLinkSnapshot::deserialize(&receiver_bytes).unwrap();
let sender_config = setup.sender_link.config().clone();
let receiver_config = setup.receiver_link.config().clone();
let sender_recipient = sender_state.recipient().clone();
let receiver_recipient = receiver_state.recipient().clone();
let mut restored_sender = restore_encrypted_link(
setup.sender_session.clone(),
sender_config.pubky_root_keypair.secret_key(),
&sender_recipient,
sender_config.outbox_client.clone(),
sender_state,
)
.await
.unwrap();
let mut restored_receiver =
restore_encrypted_link_from_config(receiver_config, &receiver_recipient, receiver_state)
.await
.unwrap();
let mut payment_endpoints_v2 = HashMap::new();
payment_endpoints_v2.insert(
PaymentEndpointIdentifier::new("btc-bitcoin-p2tr").unwrap(),
PaymentEndpointPayload::new("bc1pv2..."),
);
set_private_payment_list(
&mut restored_sender,
&private_payment_list(&payment_endpoints_v2),
)
.await
.unwrap();
let received_v2 = receive_latest_private_payment_list_for_test(&mut restored_receiver)
.await
.unwrap();
assert_eq!(received_v2.payment_endpoints.len(), 1);
assert_eq!(
received_v2
.payment_endpoints
.get(&PaymentEndpointIdentifier::new("btc-bitcoin-p2tr").unwrap()),
Some(&PaymentEndpointPayload::new("bc1pv2...")),
);
close_encrypted_link(restored_sender).await.unwrap();
close_encrypted_link(restored_receiver).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_receive_private_application_messages_returns_full_stream() {
let mut setup = PrivateTestSetup::new().await;
let mut payment_endpoints = HashMap::new();
payment_endpoints.insert(
PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
PaymentEndpointPayload::new("ln..."),
);
set_private_payment_list(
&mut setup.sender_link,
&private_payment_list(&payment_endpoints),
)
.await
.unwrap();
let unknown_json =
r#"{"version":1,"kind":"paykit.test_unknown","payload":{"note":"preserve me"}}"#;
let receipt_access_json =
r#"{"version":1,"kind":"paykit.receipt_access","payload":"raw-only"}"#;
send_raw_private_application_message(&mut setup.sender_link, unknown_json).await;
send_raw_private_application_message(&mut setup.sender_link, receipt_access_json).await;
let messages = setup
.receiver_link
.receive_private_application_messages()
.await
.unwrap();
assert_eq!(messages.len(), 3);
assert_eq!(
messages
.iter()
.map(|message| message.kind.as_deref())
.collect::<Vec<_>>(),
vec![
Some("paykit.private_payment_list"),
Some("paykit.test_unknown"),
Some("paykit.receipt_access")
]
);
assert_eq!(messages[1].raw_json, unknown_json);
assert_eq!(messages[2].raw_json, receipt_access_json);
let empty = setup
.receiver_link
.receive_private_application_messages()
.await
.unwrap();
assert!(empty.is_empty());
close_encrypted_link(setup.receiver_link).await.unwrap();
close_encrypted_link(setup.sender_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_encrypted_link_restore_rejects_mismatched_remote_pubkey() {
let setup = PrivateTestSetup::new().await;
let snapshot = setup.sender_link.snapshot().unwrap();
let sender_config = setup.sender_link.config().clone();
let wrong_remote = setup.sender_session.info().public_key().clone();
let result = restore_encrypted_link_from_config(sender_config, &wrong_remote, snapshot).await;
let err = match result {
Ok(_) => panic!("restore should reject mismatched remote pubkey"),
Err(err) => err,
};
assert!(
matches!(err, PaykitError::Validation(ref msg) if msg.contains("does not match snapshot recipient")),
"expected Validation mismatch error, got: {err}"
);
close_encrypted_link(setup.sender_link).await.unwrap();
close_encrypted_link(setup.receiver_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_encrypted_link_serialize_convenience() {
let setup = PrivateTestSetup::new().await;
let via_snapshot = setup.sender_link.snapshot().unwrap().serialize();
let via_convenience = setup.sender_link.serialize().unwrap();
assert_eq!(
via_snapshot, via_convenience,
"serialize() should equal snapshot().serialize()"
);
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_prepared_private_message_rejects_oversized_payload_without_advancing() {
let mut setup = PrivateTestSetup::new().await;
let mut payload = serde_json::json!({
"version": 1,
"kind": "paykit.test_packet",
"app_id": "bitkit",
"payload": "",
});
payload["payload"] = "x"
.repeat(pubky_noise::snow_crypto::PUBKY_NOISE_MSG_LEN + 1 - payload.to_string().len())
.into();
let raw_json = payload.to_string();
let before = setup.sender_link.serialize().unwrap();
assert!(matches!(
setup.sender_link.prepare_private_application_message_json(&raw_json),
Err(PaykitError::Validation(message)) if message.contains("exceeds max message size")
));
assert_eq!(setup.sender_link.serialize().unwrap(), before);
payload["payload"] = "".into();
assert!(setup
.sender_link
.prepare_private_application_message_json(&payload.to_string())
.is_ok());
}
#[tokio::test]
async fn test_prepared_private_message_advances_only_after_persistence_acknowledgement() {
let mut setup = PrivateTestSetup::new().await;
let mut payload = serde_json::json!({
"version": 1,
"kind": "paykit.test_packet",
"app_id": "bitkit",
"payload": "",
});
payload["payload"] = "x"
.repeat(pubky_noise::snow_crypto::PUBKY_NOISE_MSG_LEN - payload.to_string().len())
.into();
let raw_json = payload.to_string();
let sender_before = setup.sender_link.serialize().unwrap();
let link_id = setup.sender_link.snapshot().unwrap().link_id();
assert!(link_id.is_some());
let prepared_send = setup
.sender_link
.prepare_private_application_message_json(&raw_json)
.unwrap();
assert_eq!(prepared_send.resulting_snapshot().link_id(), link_id);
let sender_after = prepared_send.resulting_snapshot().serialize();
let destination_path = prepared_send.destination_path().to_owned();
let ciphertext = prepared_send.ciphertext().to_vec();
assert_eq!(
ciphertext.len(),
pubky_noise::snow_crypto::PUBKY_NOISE_TRANSPORT_PACKET_LEN
);
assert_eq!(ciphertext.len(), 1018);
let prepared_debug = format!("{prepared_send:?}");
assert!(!prepared_debug.contains(&destination_path));
assert!(setup.sender_link.serialize().is_err());
assert_ne!(sender_after, sender_before);
setup
.sender_link
.acknowledge_persisted_private_send(prepared_send)
.unwrap();
assert_eq!(setup.sender_link.serialize().unwrap(), sender_after);
setup
.sender_link
.publish_prepared_private_application_message(&destination_path, &ciphertext)
.await
.unwrap();
let receiver_before = setup.receiver_link.serialize().unwrap();
let prepared_receive = setup
.receiver_link
.prepare_next_private_application_message()
.await
.unwrap()
.expect("prepared message should be available");
let receiver_after = prepared_receive.resulting_snapshot().serialize();
assert_eq!(prepared_receive.resulting_snapshot().link_id(), link_id);
assert_eq!(prepared_receive.message().raw_json, raw_json);
assert!(setup.receiver_link.serialize().is_err());
assert_ne!(receiver_after, receiver_before);
setup
.receiver_link
.acknowledge_persisted_private_receive(prepared_receive)
.unwrap();
assert_eq!(setup.receiver_link.serialize().unwrap(), receiver_after);
close_encrypted_link(setup.sender_link).await.unwrap();
close_encrypted_link(setup.receiver_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_encrypted_link_snapshot_deserialize_rejects_garbage() {
let result = EncryptedLinkSnapshot::deserialize(&[0u8; 10]);
assert!(result.is_err(), "deserializing garbage should fail");
let err = result.unwrap_err();
assert!(
matches!(err, PaykitError::InvalidData { .. }),
"expected InvalidData error, got: {err}"
);
}
#[tokio::test]
async fn test_encrypted_link_snapshot_deserialize_rejects_wrong_length() {
let result = EncryptedLinkSnapshot::deserialize(&[0u8; 189]);
assert!(
matches!(result, Err(PaykitError::InvalidData { .. })),
"snapshots with the wrong serialized length should fail"
);
}
#[test]
fn test_encrypted_link_snapshot_deserialize_accepts_max_usable_noise_nonce() {
let state = transport_snapshot_state_with_nonces(u64::MAX - 1, u64::MAX - 1);
let mut bytes = state.serialize();
bytes.extend_from_slice(&Keypair::random().public_key().as_inner().to_bytes());
bytes.extend_from_slice(&[0; 72]);
let snapshot = EncryptedLinkSnapshot::deserialize(&bytes).unwrap();
assert_eq!(snapshot.serialize(), bytes);
}
#[test]
fn test_encrypted_link_snapshot_deserialize_rejects_reserved_noise_nonce() {
for (sending_nonce, receiving_nonce) in [(u64::MAX, 0), (0, u64::MAX)] {
let bytes =
transport_snapshot_state_with_nonces(sending_nonce, receiving_nonce).serialize();
let mut bytes = bytes;
bytes.extend_from_slice(&Keypair::random().public_key().as_inner().to_bytes());
bytes.extend_from_slice(&[0; 72]);
assert!(
matches!(
EncryptedLinkSnapshot::deserialize(&bytes),
Err(PaykitError::InvalidData { .. })
),
"reserved Noise nonce should be rejected"
);
}
}
#[tokio::test]
async fn test_private_message_probe_rejects_exhausted_receive_cursors() {
let mut builder = pubky::PubkyHttpClient::builder();
builder
.pkarr(|pkarr| {
pkarr
.no_default_network()
.relays(&["http://127.0.0.1:1"])
.unwrap()
})
.request_timeout(std::time::Duration::from_millis(100));
let storage = pubky::Pubky::with_client(builder.build().unwrap()).public_storage();
let identity = Keypair::random().public_key();
for (read_counter, receiving_nonce) in [(u32::MAX - 1, 0), (3, u64::MAX - 1)] {
let mut state = transport_snapshot_state_with_nonces(0, receiving_nonce);
state.read_counter = read_counter;
let mut bytes = state.serialize();
bytes.extend_from_slice(&Keypair::random().public_key().as_inner().to_bytes());
bytes.extend_from_slice(&[0; 72]);
let snapshot = EncryptedLinkSnapshot::deserialize(&bytes).unwrap();
assert!(matches!(
snapshot
.has_pending_private_application_message(&storage, &identity, &[2; 32])
.await,
Err(PaykitError::Validation(_))
));
}
}
#[tokio::test]
async fn test_malformed_private_application_message_packet_is_rejected() {
let mut setup = PrivateTestSetup::new().await;
let mut receiver_link = setup.receiver_link;
let receive_path = receiver_link.next_receive_path_for_test().unwrap();
let outbox_path = receive_path
.split_once('/')
.map(|(_, storage_path)| storage_path.to_string())
.expect("receive path should include an owner segment");
let packet_len = pubky_noise::snow_crypto::PUBKY_NOISE_TRANSPORT_PACKET_LEN;
let json = r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"bitkit","payment_endpoints":{}}"#;
send_raw_private_application_message(&mut setup.sender_link, json).await;
let ciphertext = setup
.sender_session
.storage()
.get(outbox_path.clone())
.await
.unwrap()
.bytes()
.await
.unwrap()
.to_vec();
assert_eq!(ciphertext.len(), packet_len);
let checkpoint = receiver_link.serialize().unwrap();
let mut tampered = ciphertext.clone();
tampered[0] ^= 1;
let malformed_packets = [
Vec::new(),
vec![0x00],
vec![0xff, 0xff, 0x00],
ciphertext[..packet_len - 1].to_vec(),
vec![0; packet_len],
vec![0; packet_len + 1],
tampered,
];
for packet in malformed_packets {
setup
.sender_session
.storage()
.put(outbox_path.clone(), packet)
.await
.expect("malformed outbox fixture should be stored");
let result = receiver_link.receive_private_application_messages().await;
assert!(
result.is_err(),
"malformed packet must be rejected as an error, not panic"
);
assert_eq!(receiver_link.serialize().unwrap(), checkpoint);
assert!(receiver_link
.prepare_next_private_application_message()
.await
.is_err());
assert_eq!(receiver_link.serialize().unwrap(), checkpoint);
}
setup
.sender_session
.storage()
.put(outbox_path, ciphertext)
.await
.unwrap();
let messages = receiver_link
.receive_private_application_messages()
.await
.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].raw_json, json);
close_encrypted_link(receiver_link).await.unwrap();
close_encrypted_link(setup.sender_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_send_retries_republish_after_write_failure() {
let mut setup = PrivateTestSetup::new().await;
setup.sender_link.enable_write_failure_for_test();
let json = r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{}}"#;
let err = setup
.sender_link
.send_private_application_message_for_test(json.as_bytes())
.await
.expect_err("simulated outbox write failure should fail the send");
let message = err.to_string();
assert!(
message.contains("after 4 attempts"),
"expected the retry budget to be exhausted by write failures, got: {message}"
);
close_encrypted_link(setup.sender_link).await.unwrap();
close_encrypted_link(setup.receiver_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}
#[tokio::test]
async fn test_send_can_recover_retained_packet_after_write_failure() {
let mut setup = PrivateTestSetup::new().await;
setup.sender_link.enable_write_failure_for_test();
let json = r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"test-app","payment_endpoints":{}}"#;
setup
.sender_link
.send_private_application_message_for_test(json.as_bytes())
.await
.expect_err("simulated outbox write failure should fail the send");
assert!(setup.sender_link.snapshot().is_err());
setup.sender_link.disable_write_failure_for_test();
setup.sender_link.retry_pending_send().await.unwrap();
setup.sender_link.snapshot().unwrap();
let mut receiver_link = setup.receiver_link;
let received = receiver_link
.receive_private_application_messages()
.await
.unwrap();
assert_eq!(received.len(), 1);
assert_eq!(received[0].raw_json, json);
close_encrypted_link(setup.sender_link).await.unwrap();
close_encrypted_link(receiver_link).await.unwrap();
setup.sender_session.signout().await.unwrap();
setup.receiver_session.signout().await.unwrap();
}