lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }