iris-chat 0.1.66

Iris Chat command line client and shared encrypted chat core
Documentation
use super::*;

#[test]
fn start_linked_device_stores_bounded_bootstrap_without_publishing_request_event() {
    let approval_relay = crate::local_relay::TestRelay::start();
    let temp_dir = tempfile::TempDir::new().expect("temp dir");
    let mut core = AppCore::new(
        flume::unbounded().0,
        flume::unbounded().0,
        temp_dir.path().to_string_lossy().to_string(),
        Arc::new(RwLock::new(AppState::empty())),
    );
    core.preferences.nostr_relay_urls = vec!["wss://ordinary.example".to_string()];
    core.device_approval_relay_urls = relay_urls_from_strings(&[approval_relay.url().to_string()]);

    core.handle_action(AppAction::SetCurrentDeviceLabels {
        device_label: "abcdefghijklmnop more".to_string(),
        client_label: "Iris Chat Web".to_string(),
    });
    core.handle_action(AppAction::StartLinkedDevice {
        owner_input: String::new(),
    });

    let snapshot = core
        .state
        .link_device
        .as_ref()
        .unwrap_or_else(|| panic!("link-device snapshot; toast={:?}", core.state.toast))
        .clone();
    let bootstrap = parse_nostr_identity_device_approval_bootstrap(&snapshot.url, &[])
        .expect("parse bootstrap")
        .expect("device approval bootstrap");
    assert!(snapshot
        .url
        .starts_with("nostr-identity://device-approval/"));
    assert_eq!(
        serde_json::to_value(&bootstrap)
            .expect("bootstrap JSON")
            .as_object()
            .expect("bootstrap object")
            .keys()
            .cloned()
            .collect::<std::collections::BTreeSet<_>>(),
        ["deviceAppKeyNpub", "label", "requestNpub", "requestSecret"]
            .into_iter()
            .map(str::to_string)
            .collect()
    );
    assert_eq!(bootstrap.label.as_deref(), Some("abcdefghijklmnop"));
    assert_eq!(bootstrap.label.as_ref().map(String::len), Some(16));
    assert!(
        relay_events(&approval_relay).is_empty(),
        "starting a link must not publish a request event"
    );
    PublicKey::parse(&bootstrap.request_npub).expect("request npub");
    assert_eq!(
        bootstrap.request_secret.len(),
        43,
        "nostr-identity encodes 32 request-secret bytes as unpadded base64url"
    );
    assert!(bootstrap
        .request_secret
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')));
    let pairing_client = core
        .pending_linked_device
        .as_ref()
        .expect("pending link")
        .pairing_client
        .clone();
    let pending_approval_relays = core.runtime.block_on(async {
        for _ in 0..50 {
            let relays = pairing_client
                .relays()
                .await
                .keys()
                .map(ToString::to_string)
                .collect::<Vec<_>>();
            if !relays.is_empty() {
                return relays;
            }
            sleep(Duration::from_millis(10)).await;
        }
        Vec::new()
    });
    assert_eq!(pending_approval_relays, vec![approval_relay.url()]);
    assert!(!pending_approval_relays
        .iter()
        .any(|relay| relay.contains("ordinary.example")));
    assert_eq!(bootstrap.device_app_key_npub, snapshot.device_input);
    assert!(matches!(core.screen_stack.as_slice(), [Screen::AddDevice]));
}

pub(super) fn signed_app_keys_authorization_event(
    owner: &Keys,
    device_pubkey: PublicKey,
    created_at: u64,
) -> Event {
    AppKeys::new(vec![DeviceEntry::new(device_pubkey, created_at)])
        .get_event_at(owner.public_key(), created_at)
        .sign_with_keys(owner)
        .expect("signed app keys authorization")
}

pub(super) fn device_approval_bootstrap_for_test(
    device_keys: &Keys,
    request_keys: &Keys,
    request_secret: &str,
    label: Option<&str>,
) -> String {
    let local_request = create_nostr_identity_device_approval_request(
        device_keys,
        CreateNostrIdentityDeviceApprovalRequestOptions {
            request_keys: Some(request_keys.clone()),
            request_secret: Some(request_secret.to_string()),
            requested_at: 41,
            request_type: Some("device_link".to_string()),
            resources: Vec::new(),
            expires_at: None,
            profile_id: None,
            admin_app_key_pubkey: None,
            label: label.map(str::to_string),
        },
    )
    .expect("approval request");
    let bootstrap =
        nostr_identity_device_approval_bootstrap(&local_request.request).expect("bootstrap");
    encode_nostr_identity_device_approval_bootstrap(&bootstrap, None).expect("encode bootstrap")
}

pub(super) fn dispatch_device_approval_for_test(
    core: &mut AppCore,
    relay_url: &str,
    bootstrap: String,
) {
    core.device_approval_relay_urls = relay_urls_from_strings(&[relay_url.to_string()]);
    core.handle_action(AppAction::AddAuthorizedDevice {
        device_input: bootstrap,
    });
}

pub(super) fn relay_events(relay: &crate::local_relay::TestRelay) -> Vec<Event> {
    relay
        .events()
        .into_iter()
        .filter_map(|event| serde_json::from_value::<Event>(event).ok())
        .collect()
}

#[test]
fn bootstrap_only_link_finishes_pairing_and_authorizes_linked_device() {
    let owner = Keys::generate();
    let ordinary_relay = crate::local_relay::TestRelay::start();
    let approval_relay = crate::local_relay::TestRelay::start();
    let temp_dir_primary = tempfile::TempDir::new().expect("primary temp dir");
    let temp_dir_linked = tempfile::TempDir::new().expect("linked temp dir");
    let mut primary = AppCore::new(
        flume::unbounded().0,
        flume::unbounded().0,
        temp_dir_primary.path().to_string_lossy().to_string(),
        Arc::new(RwLock::new(AppState::empty())),
    );
    primary.preferences.nostr_relay_urls = vec![ordinary_relay.url().to_string()];
    primary
        .start_primary_session(owner.clone(), owner.clone(), false, false)
        .expect("primary session");
    primary.pending_relay_publishes.clear();

    let mut linked = AppCore::new(
        flume::unbounded().0,
        flume::unbounded().0,
        temp_dir_linked.path().to_string_lossy().to_string(),
        Arc::new(RwLock::new(AppState::empty())),
    );
    linked.preferences.nostr_relay_urls.clear();
    linked.device_approval_relay_urls =
        relay_urls_from_strings(&[approval_relay.url().to_string()]);
    linked.handle_action(AppAction::SetCurrentDeviceLabels {
        device_label: "Safari on macOS".to_string(),
        client_label: "Iris Chat Web".to_string(),
    });
    linked.handle_action(AppAction::StartLinkedDevice {
        owner_input: String::new(),
    });
    let approval_bootstrap = linked
        .state
        .link_device
        .as_ref()
        .expect("approval bootstrap")
        .url
        .clone();
    let linked_device_hex = linked
        .pending_linked_device
        .as_ref()
        .unwrap_or_else(|| panic!("pending linked device; toast={:?}", linked.state.toast))
        .device_keys
        .public_key()
        .to_hex();

    assert!(
        !primary.device_sync_has_sibling_tcp_for_test(),
        "an unlinked profile has no sibling history service"
    );
    primary.device_approval_relay_urls =
        relay_urls_from_strings(&[approval_relay.url().to_string()]);
    primary.handle_action(AppAction::AddAuthorizedDeviceWithHistory {
        device_input: approval_bootstrap,
        include_message_history: true,
    });
    assert!(primary
        .device_history_transfer(&linked_device_hex)
        .is_some_and(|record| record.outbound && record.since == 0));
    assert!(
        primary.device_sync_has_sibling_tcp_for_test(),
        "successful approval must start history without an echoed roster event"
    );

    assert_eq!(primary.state.toast.as_deref(), Some("Device added"));
    assert!(
        relay_events(&approval_relay)
            .iter()
            .chain(relay_events(&ordinary_relay).iter())
            .all(|event| event.kind != Kind::Custom(30078)
                && !event.content.contains("iris-chat-history")),
        "history choice stays on the private linking pair, never public relays"
    );
    let approval_events = relay_events(&approval_relay);
    let response_event = approval_events
        .into_iter()
        .find(|event| event.kind.as_u16() as u32 == INVITE_RESPONSE_KIND)
        .expect("approval publishes deterministic invite response");
    let approval_events = relay_events(&approval_relay);
    let app_keys_event = approval_events
        .into_iter()
        .find(|event| {
            event.kind.as_u16() as u32 == APP_KEYS_EVENT_KIND
                && event_has_tag_value(event, "device", &linked_device_hex)
        })
        .expect("approval publishes AppKeys authorization");
    let receipt_deadline = Instant::now() + Duration::from_secs(2);
    let receipt_event = loop {
        if let Some(event) = relay_events(&approval_relay).into_iter().find(|event| {
            event_has_tag_value(event, "type", "nostr_identity_device_approval_receipt")
        }) {
            break event;
        }
        assert!(
            Instant::now() < receipt_deadline,
            "approval should publish the optional encrypted receipt"
        );
        std::thread::sleep(Duration::from_millis(10));
    };
    assert!(relay_events(&approval_relay).iter().all(|event| {
        !event_has_tag_value(event, "type", "nostr_identity_device_approval_request")
    }));
    let approval_event_ids = [
        response_event.id.to_string(),
        app_keys_event.id.to_string(),
        receipt_event.id.to_string(),
    ];
    let ordinary_event_ids = relay_events(&ordinary_relay)
        .into_iter()
        .map(|event| event.id.to_string())
        .collect::<HashSet<_>>();
    assert!(approval_event_ids
        .iter()
        .all(|event_id| !ordinary_event_ids.contains(event_id)));

    linked.handle_relay_event(response_event);
    assert!(
        linked.pending_linked_device.is_some(),
        "invite response alone must wait for device approval"
    );

    linked.handle_relay_event(app_keys_event);
    assert!(
        linked.pending_linked_device.is_none(),
        "the QR-bound handshake and exact AppKeys authorization complete linking"
    );
    linked.handle_relay_event(receipt_event);
    let linked_device = linked
        .logged_in
        .as_ref()
        .expect("linked session")
        .device_keys
        .public_key();

    linked.refresh_local_authorization_state();
    linked.rebuild_state();
    let logged_in = linked.logged_in.as_ref().expect("linked logged in");
    let active_session_count = linked
        .protocol_engine
        .as_ref()
        .map(|engine| engine.active_session_count_for_owner(owner.public_key()))
        .unwrap_or_default();
    assert_eq!(logged_in.owner_pubkey, owner.public_key());
    assert_eq!(
        logged_in.authorization_state,
        LocalAuthorizationState::Authorized,
        "linked_device={} active_sessions={} app_keys={:?} debug={:?}",
        linked_device.to_hex(),
        active_session_count,
        linked.app_keys,
        linked.debug_log
    );
    assert!(linked.pending_linked_device.is_none());
    assert!(active_session_count > 0);
    let roster = linked
        .app_keys
        .get(&owner.public_key().to_hex())
        .expect("linked learned owner roster");
    assert!(roster
        .devices
        .iter()
        .any(|device| device.identity_pubkey_hex == linked_device.to_hex()));
    let linked_roster_device = primary
        .app_keys
        .get(&owner.public_key().to_hex())
        .and_then(|roster| {
            roster
                .devices
                .iter()
                .find(|device| device.identity_pubkey_hex == linked_device.to_hex())
        })
        .expect("primary signed linked device labels");
    assert_eq!(
        linked_roster_device.device_label.as_deref(),
        Some("Safari on macOS")
    );
}