client_state_machine.rs (11349B)
1 #[path = "../src/test_fixtures.rs"] 2 mod test_fixtures; 3 4 use nostr::nips::nip44::{self, Version}; 5 use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag}; 6 use radroots_nostr_connect::client::{ 7 CLIENT_EVENT_MAX_BYTES, CancellationPhase, CancellationToken, ClientEvent, Completion, 8 EventOutcome, Progress, Receive, Target, Transport, TransportFuture, 9 }; 10 use radroots_nostr_connect::message::{RPC_KIND, RequestId}; 11 use radroots_nostr_connect::uri::RelayUrl; 12 use radroots_nostr_connect::{Client, Error, Request, Response}; 13 use std::collections::VecDeque; 14 use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, RELAY_PRIMARY_WSS}; 15 16 fn keys(secret_key_hex: &str) -> Keys { 17 Keys::new(SecretKey::from_hex(secret_key_hex).expect("secret key")) 18 } 19 20 fn identity_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { 21 radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") 22 } 23 24 fn client(remote_keys: &Keys) -> Client { 25 Client::from_secret( 26 FIXTURE_ALICE.secret_key_hex, 27 Target::try_new( 28 identity_public_key(remote_keys.public_key()), 29 vec![RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")], 30 ) 31 .expect("target"), 32 ) 33 .expect("client") 34 } 35 36 fn response_event( 37 remote_keys: &Keys, 38 client_public_key: radroots_identity::PublicKey, 39 request_id: &str, 40 response: Response, 41 ) -> ClientEvent { 42 let client_public_key = 43 radroots_nostr::key::public_key_to_nostr(client_public_key).expect("client public key"); 44 let envelope = response 45 .into_envelope(request_id) 46 .expect("response envelope"); 47 let payload = serde_json::to_string(&envelope).expect("response JSON"); 48 let ciphertext = nip44::encrypt( 49 remote_keys.secret_key(), 50 &client_public_key, 51 payload, 52 Version::V2, 53 ) 54 .expect("response encryption"); 55 let event = EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) 56 .tag(Tag::public_key(client_public_key)) 57 .sign_with_keys(remote_keys) 58 .expect("response event"); 59 ClientEvent::from_json(&serde_json::to_string(&event).expect("event JSON")) 60 .expect("client event") 61 } 62 63 struct MockTransport { 64 published: Vec<ClientEvent>, 65 incoming: VecDeque<Receive>, 66 cancel_on_publish: Option<CancellationToken>, 67 } 68 69 impl MockTransport { 70 fn new(incoming: impl IntoIterator<Item = Receive>) -> Self { 71 Self { 72 published: Vec::new(), 73 incoming: incoming.into_iter().collect(), 74 cancel_on_publish: None, 75 } 76 } 77 78 fn cancelling_on_publish(token: CancellationToken) -> Self { 79 Self { 80 published: Vec::new(), 81 incoming: VecDeque::new(), 82 cancel_on_publish: Some(token), 83 } 84 } 85 } 86 87 impl Transport for MockTransport { 88 fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()> { 89 self.published.push(event); 90 if let Some(token) = self.cancel_on_publish.as_ref() { 91 token.cancel(); 92 } 93 Box::pin(async { Ok(()) }) 94 } 95 96 fn receive<'a>( 97 &'a mut self, 98 cancellation: &'a CancellationToken, 99 ) -> TransportFuture<'a, Receive> { 100 let outcome = if cancellation.is_cancelled() { 101 Receive::Cancelled 102 } else { 103 self.incoming.pop_front().unwrap_or(Receive::TimedOut) 104 }; 105 Box::pin(async move { Ok(outcome) }) 106 } 107 } 108 109 async fn execute_through_dyn_transport( 110 client: &Client, 111 transport: &mut dyn Transport, 112 request_id: &str, 113 ) -> Result<Completion, Error> { 114 client 115 .execute( 116 RequestId::parse(request_id)?, 117 Request::Ping, 118 transport, 119 &CancellationToken::new(), 120 |_| Ok(()), 121 ) 122 .await 123 } 124 125 #[tokio::test] 126 async fn client_executes_through_dyn_transport_without_runtime_ownership() { 127 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 128 let client = client(&remote_keys); 129 let response = response_event( 130 &remote_keys, 131 client.public_key().expect("client public key"), 132 "request-dyn", 133 Response::Pong, 134 ); 135 let mut transport = MockTransport::new([Receive::event(response)]); 136 assert_eq!( 137 execute_through_dyn_transport(&client, &mut transport, "request-dyn") 138 .await 139 .expect("completion"), 140 Completion::response(Response::Pong) 141 ); 142 } 143 144 #[tokio::test] 145 async fn client_completes_happy_path_after_one_publication() { 146 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 147 let client = client(&remote_keys); 148 let response = response_event( 149 &remote_keys, 150 client.public_key().expect("client public key"), 151 "request-happy", 152 Response::Pong, 153 ); 154 let mut transport = MockTransport::new([Receive::event(response)]); 155 156 let completion = client 157 .execute( 158 RequestId::parse("request-happy").expect("request id"), 159 Request::Ping, 160 &mut transport, 161 &CancellationToken::new(), 162 |_| Ok(()), 163 ) 164 .await 165 .expect("completion"); 166 167 assert_eq!(completion, Completion::response(Response::Pong)); 168 assert_eq!(transport.published.len(), 1); 169 assert!(!transport.published[0].as_json().contains("request-happy")); 170 } 171 172 #[tokio::test] 173 async fn client_reports_auth_progress_before_completion() { 174 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 175 let client = client(&remote_keys); 176 let client_public_key = client.public_key().expect("client public key"); 177 let mut transport = MockTransport::new([ 178 Receive::event(response_event( 179 &remote_keys, 180 client_public_key, 181 "request-auth", 182 Response::AuthUrl("https://auth.example.test/challenge".to_owned()), 183 )), 184 Receive::event(response_event( 185 &remote_keys, 186 client_public_key, 187 "request-auth", 188 Response::Pong, 189 )), 190 ]); 191 let mut progress = Vec::new(); 192 193 let completion = client 194 .execute( 195 RequestId::parse("request-auth").expect("request id"), 196 Request::Ping, 197 &mut transport, 198 &CancellationToken::new(), 199 |event| { 200 progress.push(event); 201 Ok(()) 202 }, 203 ) 204 .await 205 .expect("completion"); 206 207 assert_eq!(completion, Completion::response(Response::Pong)); 208 assert_eq!( 209 progress, 210 [Progress::AuthChallenge { 211 url: "https://auth.example.test/challenge".to_owned(), 212 }] 213 ); 214 } 215 216 #[tokio::test] 217 async fn transport_owns_timeout_handoff() { 218 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 219 let client = client(&remote_keys); 220 let mut transport = MockTransport::new([Receive::TimedOut]); 221 222 let error = client 223 .execute( 224 RequestId::parse("request-timeout").expect("request id"), 225 Request::Ping, 226 &mut transport, 227 &CancellationToken::new(), 228 |_| Ok(()), 229 ) 230 .await 231 .expect_err("timeout"); 232 233 assert_eq!(error, Error::RequestTimedOut); 234 assert_eq!(transport.published.len(), 1); 235 } 236 237 #[tokio::test] 238 async fn cancellation_distinguishes_before_and_after_publication() { 239 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 240 let client = client(&remote_keys); 241 242 let before = CancellationToken::new(); 243 before.cancel(); 244 let mut before_transport = MockTransport::new([]); 245 assert_eq!( 246 client 247 .execute( 248 RequestId::parse("request-cancel-before").expect("request id"), 249 Request::Ping, 250 &mut before_transport, 251 &before, 252 |_| Ok(()), 253 ) 254 .await 255 .expect("before-publication cancellation"), 256 Completion::Cancelled(CancellationPhase::BeforePublication) 257 ); 258 assert!(before_transport.published.is_empty()); 259 260 let after = CancellationToken::new(); 261 let mut after_transport = MockTransport::cancelling_on_publish(after.clone()); 262 assert_eq!( 263 client 264 .execute( 265 RequestId::parse("request-cancel-after").expect("request id"), 266 Request::Ping, 267 &mut after_transport, 268 &after, 269 |_| Ok(()), 270 ) 271 .await 272 .expect("after-publication cancellation"), 273 Completion::Cancelled(CancellationPhase::AfterPublication) 274 ); 275 assert_eq!(after_transport.published.len(), 1); 276 } 277 278 #[test] 279 fn state_machine_ignores_wrong_response_then_completes() { 280 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 281 let client = client(&remote_keys); 282 let client_public_key = client.public_key().expect("client public key"); 283 let mut operation = client 284 .prepare( 285 RequestId::parse("request-select").expect("request id"), 286 Request::Ping, 287 ) 288 .expect("operation"); 289 operation.mark_published().expect("published"); 290 291 let wrong = response_event( 292 &remote_keys, 293 client_public_key, 294 "other-request", 295 Response::Pong, 296 ); 297 assert_eq!( 298 operation.select(&wrong).expect("wrong response"), 299 EventOutcome::Ignore 300 ); 301 let expected = response_event( 302 &remote_keys, 303 client_public_key, 304 "request-select", 305 Response::Pong, 306 ); 307 assert_eq!( 308 operation.select(&expected).expect("expected response"), 309 EventOutcome::Complete(Box::new(Response::Pong)) 310 ); 311 } 312 313 #[test] 314 fn state_machine_rejects_duplicate_response_event() { 315 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 316 let client = client(&remote_keys); 317 let response = response_event( 318 &remote_keys, 319 client.public_key().expect("client public key"), 320 "request-replay", 321 Response::Pong, 322 ); 323 let mut operation = client 324 .prepare( 325 RequestId::parse("request-replay").expect("request id"), 326 Request::Ping, 327 ) 328 .expect("operation"); 329 operation.mark_published().expect("published"); 330 assert_eq!( 331 operation.select(&response).expect("first response"), 332 EventOutcome::Complete(Box::new(Response::Pong)) 333 ); 334 assert_eq!( 335 operation.select(&response).expect_err("duplicate response"), 336 Error::ReplayedResponse 337 ); 338 } 339 340 #[test] 341 fn client_key_and_event_diagnostics_are_redacted() { 342 let remote_keys = keys(FIXTURE_BOB.secret_key_hex); 343 assert_eq!( 344 Client::from_secret("not-a-secret", client(&remote_keys).target().clone()) 345 .expect_err("invalid key"), 346 Error::InvalidClientKey 347 ); 348 assert!(!format!("{:?}", client(&remote_keys)).contains(FIXTURE_ALICE.secret_key_hex)); 349 assert_eq!( 350 ClientEvent::from_json("not an event").expect_err("invalid event"), 351 Error::InvalidClientEvent 352 ); 353 assert_eq!( 354 ClientEvent::from_json(&"x".repeat(CLIENT_EVENT_MAX_BYTES + 1)) 355 .expect_err("oversized event"), 356 Error::InvalidClientEvent 357 ); 358 let progress = Progress::AuthChallenge { 359 url: "https://auth.example.test/?token=do-not-log".to_owned(), 360 }; 361 assert!(!format!("{progress:?}").contains("do-not-log")); 362 }