iris-chat 0.1.61

Iris Chat command line client and shared encrypted chat core
Documentation
#[test]
fn pending_linked_device_finishes_when_owner_accepts_invite() {
    let owner = Keys::generate();
    let temp_dir = tempfile::TempDir::new().expect("temp dir");
    let (update_tx, update_rx) = flume::unbounded();
    let mut core = AppCore::new(
        update_tx,
        flume::unbounded().0,
        temp_dir.path().to_string_lossy().to_string(),
        Arc::new(RwLock::new(AppState::empty())),
    );
    core.preferences.nostr_relay_urls.clear();
    core.handle_action(AppAction::StartLinkedDevice {
        owner_input: String::new(),
    });
    let _ = update_rx.try_iter().collect::<Vec<_>>();

    let linked_device_pubkey = core
        .pending_linked_device
        .as_ref()
        .expect("pending link invite")
        .device_keys
        .public_key();
    let pending = core
        .pending_linked_device
        .as_ref()
        .expect("pending link invite");
    let (_owner_session, response_envelope) = pending
        .pairing_invite
        .accept_with_owner(
            owner.public_key(),
            owner.secret_key().to_secret_bytes(),
            Some(owner.public_key().to_hex()),
            Some(owner.public_key()),
        )
        .expect("owner accepts");
    let response_event = nostr_double_ratchet::invite_response_event(&response_envelope)
        .expect("invite response event");

    core.handle_relay_event(response_event);
    assert!(
        core.pending_linked_device.is_some(),
        "invite response waits for owner-signed AppKeys authorization"
    );
    assert!(core.logged_in.is_none());

    core.handle_relay_event(signed_app_keys_authorization_event(
        &owner,
        Keys::generate().public_key(),
        41,
    ));
    assert!(
        core.pending_linked_device.is_some(),
        "an owner-signed roster that does not contain this QR's device key must be ignored"
    );

    core.handle_relay_event(signed_app_keys_authorization_event(
        &owner,
        linked_device_pubkey,
        42,
    ));

    let logged_in = core.logged_in.as_ref().expect("linked session");
    assert_eq!(logged_in.owner_pubkey, owner.public_key());
    assert_eq!(
        logged_in.authorization_state,
        LocalAuthorizationState::Authorized
    );
    assert!(core.pending_linked_device.is_none());
    assert!(core
        .protocol_engine
        .as_ref()
        .is_some_and(|engine| engine.active_session_count_for_owner(owner.public_key()) > 0));

    let completion_updates = update_rx.try_iter().collect::<Vec<_>>();
    assert!(completion_updates
        .iter()
        .any(|update| matches!(update, AppUpdate::PersistAccountBundle { .. })));
    assert!(completion_updates
        .iter()
        .all(|update| !matches!(update, AppUpdate::ClearPendingDeviceLink)));
    assert!(completion_updates.iter().all(|update| {
        !matches!(
            update,
            AppUpdate::FullState(state)
                if state.account.as_ref().is_some_and(|account| {
                    account.authorization_state == DeviceAuthorizationState::AwaitingApproval
                })
        )
    }), "link completion must never expose an intermediate awaiting-approval account");
    assert!(completion_updates.iter().any(|update| matches!(
        update,
        AppUpdate::FullState(state)
            if state.router.default_screen == Screen::ChatList
                && state.account.as_ref().is_some_and(|account| {
                    account.authorization_state == DeviceAuthorizationState::Authorized
                })
    )));
}

#[test]
fn legacy_incomplete_link_can_logout_and_create_fresh_account() {
    let old_owner = Keys::generate();
    let old_device = Keys::generate();
    let temp_dir = tempfile::TempDir::new().expect("temp dir");
    let (update_tx, update_rx) = flume::unbounded();
    let mut core = AppCore::new(
        update_tx,
        flume::unbounded().0,
        temp_dir.path().to_string_lossy().to_string(),
        Arc::new(RwLock::new(AppState::empty())),
    );
    core.preferences.nostr_relay_urls.clear();
    core.start_session(
        old_owner.public_key(),
        None,
        old_device,
        false,
        false,
    )
    .expect("legacy incomplete linked session");
    assert_eq!(
        core.state
            .account
            .as_ref()
            .expect("legacy linked account")
            .authorization_state,
        DeviceAuthorizationState::AwaitingApproval
    );
    let _ = update_rx.try_iter().collect::<Vec<_>>();

    core.handle_action(AppAction::Logout);
    assert!(core.logged_in.is_none());
    assert!(core.pending_linked_device.is_none());
    assert!(core.state.account.is_none());
    assert_eq!(core.state.router.default_screen, Screen::Welcome);

    core.handle_action(AppAction::CreateAccount {
        name: "Fresh profile".to_string(),
    });
    let account = core.state.account.as_ref().expect("fresh account");
    assert_ne!(account.public_key_hex, old_owner.public_key().to_hex());
    assert_eq!(
        account.authorization_state,
        DeviceAuthorizationState::Authorized
    );
    assert_eq!(core.state.router.default_screen, Screen::ChatList);
    assert!(core.state.toast.is_none());
    assert!(update_rx.try_iter().any(|update| matches!(
        update,
        AppUpdate::PersistAccountBundle {
            owner_nsec: Some(_),
            ..
        }
    )));
}

#[test]
fn create_account_failure_reports_error_and_resets_busy_state() {
    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.screen_stack = vec![Screen::CreateAccount];

    let database = core.app_store.shared();
    let _ = std::thread::spawn(move || {
        let _database_guard = database.lock().expect("database lock");
        panic!("poison database connection");
    })
    .join();

    core.handle_action(AppAction::CreateAccount {
        name: "Fresh profile".to_string(),
    });

    assert!(core.state.account.is_none());
    assert!(!core.state.busy.creating_account);
    assert!(core
        .state
        .toast
        .as_deref()
        .is_some_and(|message| !message.trim().is_empty()));
}

#[test]
fn linked_device_code_failure_reports_error_and_resets_busy_state() {
    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.device_approval_relay_urls.clear();

    core.handle_action(AppAction::StartLinkedDevice {
        owner_input: String::new(),
    });

    assert!(core.state.link_device.is_none());
    assert!(!core.state.busy.linking_device);
    assert!(core
        .state
        .toast
        .as_deref()
        .is_some_and(|message| !message.trim().is_empty()));
}

#[test]
fn completed_pairing_discards_pairing_invite_and_creates_stable_local_invite() {
    let owner = Keys::generate();
    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.clear();
    core.handle_action(AppAction::StartLinkedDevice {
        owner_input: String::new(),
    });

    let pending = core
        .pending_linked_device
        .as_ref()
        .expect("pending link invite");
    let pairing_invite = pending.pairing_invite.clone();
    let linked_device_pubkey = pending.device_keys.public_key();
    assert_eq!(pairing_invite.purpose.as_deref(), Some("link"));
    assert!(pairing_invite.owner_public_key.is_none());

    let (_owner_session, response_envelope) = pairing_invite
        .accept_with_owner(
            owner.public_key(),
            owner.secret_key().to_secret_bytes(),
            Some(owner.public_key().to_hex()),
            Some(owner.public_key()),
        )
        .expect("owner accepts");
    let response_event = nostr_double_ratchet::invite_response_event(&response_envelope)
        .expect("invite response event");

    core.handle_relay_event(signed_app_keys_authorization_event(
        &owner,
        linked_device_pubkey,
        42,
    ));
    core.handle_relay_event(response_event);

    let stable_invite = core
        .protocol_engine
        .as_ref()
        .and_then(ProtocolEngine::local_invite)
        .expect("stable local invite");
    assert!(core.pending_linked_device.is_none());
    assert_eq!(stable_invite.owner_public_key, Some(owner.public_key()));
    assert_ne!(
        stable_invite.inviter_ephemeral_public_key,
        pairing_invite.inviter_ephemeral_public_key
    );
    assert_ne!(stable_invite.purpose.as_deref(), Some("link"));
    assert_ne!(stable_invite.max_uses, Some(1));
}

#[test]
fn pending_linked_device_rejects_unrelated_owner_roster_in_both_arrival_orders() {
    for roster_first in [false, true] {
        let owner = Keys::generate();
        let attacker = Keys::generate();
        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.clear();
        core.start_linked_device("");
        let pending = core.pending_linked_device.as_ref().expect("pending link");
        let linked_device = pending.device_keys.public_key();
        let (_, response) = pending
            .pairing_invite
            .accept_with_owner(
                owner.public_key(),
                owner.secret_key().to_secret_bytes(),
                Some(owner.public_key().to_hex()),
                Some(owner.public_key()),
            )
            .expect("owner response");
        let response = nostr_double_ratchet::invite_response_event(&response).unwrap();
        let unrelated_roster = signed_app_keys_authorization_event(&attacker, linked_device, 42);
        if roster_first {
            core.handle_relay_event(unrelated_roster);
            core.handle_relay_event(response);
        } else {
            core.handle_relay_event(response);
            core.handle_relay_event(unrelated_roster);
        }
        assert!(
            core.logged_in.is_none(),
            "unrelated roster cannot choose the account"
        );
        assert!(
            core.pending_linked_device.is_some(),
            "keep the valid pairing response"
        );

        core.handle_relay_event(signed_app_keys_authorization_event(
            &owner,
            linked_device,
            43,
        ));
        assert_eq!(
            core.logged_in.as_ref().unwrap().owner_pubkey,
            owner.public_key()
        );
    }
}

#[test]
fn pending_linked_device_requires_the_responding_device_in_the_owner_roster() {
    let owner = Keys::generate();
    let approver = Keys::generate();
    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.clear();
    core.start_linked_device("");
    let pending = core.pending_linked_device.as_ref().expect("pending link");
    let linked_device = pending.device_keys.public_key();
    let (_, response) = pending
        .pairing_invite
        .accept_with_owner(
            approver.public_key(),
            approver.secret_key().to_secret_bytes(),
            Some(owner.public_key().to_hex()),
            Some(owner.public_key()),
        )
        .expect("response claiming owner identity and device id");
    core.handle_relay_event(nostr_double_ratchet::invite_response_event(&response).unwrap());
    core.handle_relay_event(signed_app_keys_authorization_event(
        &owner,
        linked_device,
        42,
    ));
    assert!(
        core.logged_in.is_none(),
        "the response's authenticated device identity must be authorized"
    );

    let roster = AppKeys::new(vec![
        DeviceEntry::new(linked_device, 42),
        DeviceEntry::new(approver.public_key(), 43),
    ])
    .get_event_at(owner.public_key(), 43)
    .sign_with_keys(&owner)
    .unwrap();
    core.handle_relay_event(roster);
    assert_eq!(
        core.logged_in.as_ref().unwrap().owner_pubkey,
        owner.public_key()
    );
}