diff --git a/src-tauri/src/matrix_crypto/message_flow.rs b/src-tauri/src/matrix_crypto/message_flow.rs index 0304e4aa2..19e2325a9 100644 --- a/src-tauri/src/matrix_crypto/message_flow.rs +++ b/src-tauri/src/matrix_crypto/message_flow.rs @@ -187,6 +187,69 @@ fn encryption_settings() -> Value { }) } +async fn encrypt_message(peer: &Peer, body: &str) -> Value { + let encrypted = call( + peer, + "encryptRoomEvent", + json!({ + "roomId": ROOM, + "eventType": "m.room.message", + "content": json!({ "msgtype": "m.text", "body": body }).to_string(), + }), + ) + .await; + + serde_json::from_str(encrypted.as_str().unwrap()).unwrap() +} + +fn encrypted_event(content: Value, event_id: &str) -> Value { + json!({ + "event_id": event_id, + "type": "m.room.encrypted", + "sender": "@alice:example.org", + "room_id": ROOM, + "origin_server_ts": 0, + "content": content, + }) +} + +async fn decrypt_message(peer: &Peer, event: Value) -> Value { + dispatch::invoke( + &peer.machine, + "decryptRoomEvent", + json!({ + "event": event.to_string(), + "roomId": ROOM, + "decryptionSettings": { "senderDeviceTrustRequirement": 0 }, + }), + ) + .await + .unwrap() +} + +async fn deliver_shared_room_keys(alice: &Peer, bob: &Peer, shared: &Value) { + for request in shared.as_array().unwrap() { + let events = to_device_events(&alice.user, request); + call( + bob, + "receiveSyncChanges", + json!({ "toDeviceEvents": events.to_string() }), + ) + .await; + call( + alice, + "markRequestAsSent", + json!({ + "requestId": request["id"], + "requestType": request["type"], + "response": "{}", + }), + ) + .await; + } + drain_to(alice, bob).await; +} + #[tokio::test] async fn the_encrypt_event_sequence_produces_a_readable_message() { let alice = peer("@alice:example.org", "ALICEDEV", "alice").await; @@ -329,3 +392,142 @@ async fn share_room_key_accepts_the_settings_the_webview_builds() { assert!(result.is_ok(), "{:?}", result.unwrap_err()); } + +#[tokio::test] +async fn resharing_after_invalidation_rotates_the_key() { + let alice = peer("@alice:example.org", "ALICEDEV", "invalidate-alice").await; + let bob = peer("@bob:example.org", "BOBDEV", "invalidate-bob").await; + + let (alice_keys, _) = publish_keys(&alice).await; + let (bob_keys, bob_otks) = publish_keys(&bob).await; + learn_about(&alice, &bob, "BOBDEV", &bob_keys).await; + learn_about(&bob, &alice, "ALICEDEV", &alice_keys).await; + claim_session(&alice, "@bob:example.org", "BOBDEV", &bob_otks).await; + drain_to(&alice, &bob).await; + + let shared = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": ["@bob:example.org"], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + deliver_shared_room_keys(&alice, &bob, &shared).await; + + let before = encrypt_message(&alice, "before invalidation").await; + let decrypted = decrypt_message( + &bob, + encrypted_event(before.clone(), "$before-invalidation:example.org"), + ) + .await; + let clear: Value = serde_json::from_str(decrypted["event"].as_str().unwrap()).unwrap(); + assert_eq!(clear["content"]["body"], "before invalidation"); + assert_eq!( + call(&alice, "invalidateGroupSession", json!({ "roomId": ROOM }),).await, + Value::Bool(true) + ); + + let reshared = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": [], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + assert!(reshared.as_array().unwrap().is_empty()); + + let after = encrypt_message(&alice, "after invalidation").await; + let result = decrypt_message( + &bob, + encrypted_event(after.clone(), "$after-invalidation:example.org"), + ) + .await; + assert_ne!( + before["session_id"], after["session_id"], + "Bob result after invalidation: {result:?}" + ); + assert_eq!(result["className"], "DecryptionError"); + assert_eq!( + result["code"], 0, + "unexpected decryption failure: {result:?}" + ); +} + +#[tokio::test] +async fn removing_a_recipient_rotates_before_the_next_event() { + let alice = peer("@alice:example.org", "ALICEDEV", "remove-alice").await; + let bob = peer("@bob:example.org", "BOBDEV", "remove-bob").await; + + let (alice_keys, _) = publish_keys(&alice).await; + let (bob_keys, bob_otks) = publish_keys(&bob).await; + learn_about(&alice, &bob, "BOBDEV", &bob_keys).await; + learn_about(&bob, &alice, "ALICEDEV", &alice_keys).await; + claim_session(&alice, "@bob:example.org", "BOBDEV", &bob_otks).await; + drain_to(&alice, &bob).await; + + let shared = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": ["@bob:example.org"], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + deliver_shared_room_keys(&alice, &bob, &shared).await; + let before = encrypt_message(&alice, "before removal").await; + let decrypted = decrypt_message( + &bob, + encrypted_event(before.clone(), "$before-removal:example.org"), + ) + .await; + let clear: Value = serde_json::from_str(decrypted["event"].as_str().unwrap()).unwrap(); + assert_eq!(clear["content"]["body"], "before removal"); + + let unchanged = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": ["@bob:example.org"], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + assert!(unchanged.as_array().unwrap().is_empty()); + + let removed = call( + &alice, + "shareRoomKey", + json!({ + "roomId": ROOM, + "users": [], + "encryptionSettings": encryption_settings(), + }), + ) + .await; + assert!(removed.as_array().unwrap().is_empty()); + + let after = encrypt_message(&alice, "after removal").await; + let result = decrypt_message( + &bob, + encrypted_event(after.clone(), "$after-removal:example.org"), + ) + .await; + assert_ne!( + before["session_id"], after["session_id"], + "Bob result after removal: {result:?}" + ); + assert_eq!(result["className"], "DecryptionError"); + assert_eq!( + result["code"], 0, + "unexpected decryption failure: {result:?}" + ); +} diff --git a/src/app/crypto/engineCrypto/EngineCrypto.ts b/src/app/crypto/engineCrypto/EngineCrypto.ts index e2fdd4e16..674a7f71c 100644 --- a/src/app/crypto/engineCrypto/EngineCrypto.ts +++ b/src/app/crypto/engineCrypto/EngineCrypto.ts @@ -362,6 +362,8 @@ export class EngineCrypto readonly #encryptionChains = new Map>(); + readonly #roomKeyInvalidations = new Map>(); + readonly #backupUpload = createCoalescedRunner( () => this.#uploadRoomKeysToBackup().catch((error: unknown) => { @@ -721,7 +723,9 @@ export class EngineCrypto event.getStateKey() !== this.#identity.userId && event.getContent().membership !== KnownMembership.Join ) { - void this.forceDiscardSession(event.getRoomId() ?? ''); + void this.forceDiscardSession(event.getRoomId() ?? '').catch((error: unknown) => + engineCryptoLog.warn('general', 'Could not invalidate room session', error) + ); } } @@ -1025,6 +1029,7 @@ export class EngineCrypto this.#eventsPendingKey.clear(); this.#roomsWithTrackedMembers.clear(); this.#encryptionChains.clear(); + this.#roomKeyInvalidations.clear(); this.#claimChain = Promise.resolve(); this.#backupDownloader.stop(); } @@ -1092,7 +1097,7 @@ export class EngineCrypto return this.#serializeForRoom(room.roomId, () => this.#encryptEventInner(event, room)); } - async #prepareRoomForEncryption(room: Room): Promise { + async #prepareRoomForEncryption(room: Room): Promise { const members = await room.getEncryptionTargetMembers(); const users = members.map((member) => member.userId); @@ -1103,12 +1108,6 @@ export class EngineCrypto } void this.#flushOutgoingRequests(); - return users; - } - - async #encryptEventInner(event: MatrixEvent, room: Room): Promise { - // The megolm session has to reach every device in the room before the event does. - const users = await this.#prepareRoomForEncryption(room); await this.#ensureSessionsForUsers(users); @@ -1121,20 +1120,33 @@ export class EngineCrypto // eslint-disable-next-line no-await-in-loop await this.#sendTracked(request); } + } - const encrypted = (await this.#call('encryptRoomEvent', { - roomId: room.roomId, - eventType: event.getType(), - content: JSON.stringify(event.getContent()), - })) as string; - - const own = await this.getOwnDeviceKeys(); - event.makeEncrypted( - 'm.room.encrypted', - JSON.parse(encrypted) as Record, - own.curve25519, - own.ed25519 - ); + async #encryptEventInner(event: MatrixEvent, room: Room): Promise { + while (true) { + const invalidation = this.#roomKeyInvalidations.get(room.roomId); + if (invalidation) await invalidation; + + await this.#prepareRoomForEncryption(room); + if (this.#roomKeyInvalidations.get(room.roomId) !== invalidation) continue; + + const encrypted = (await this.#call('encryptRoomEvent', { + roomId: room.roomId, + eventType: event.getType(), + content: JSON.stringify(event.getContent()), + })) as string; + + const own = await this.getOwnDeviceKeys(); + if (this.#roomKeyInvalidations.get(room.roomId) !== invalidation) continue; + + event.makeEncrypted( + 'm.room.encrypted', + JSON.parse(encrypted) as Record, + own.curve25519, + own.ed25519 + ); + return; + } } async decryptEvent(event: MatrixEvent): Promise { @@ -1501,13 +1513,18 @@ export class EngineCrypto prepareToEncrypt(room: Room): void { void this.#serializeForRoom(room.roomId, async () => { - const users = await this.#prepareRoomForEncryption(room); - await this.#ensureSessionsForUsers(users); + const invalidation = this.#roomKeyInvalidations.get(room.roomId); + if (invalidation) await invalidation; + await this.#prepareRoomForEncryption(room); }).catch((error: unknown) => engineCryptoLog.warn('general', 'prepareToEncrypt failed', error)); } async forceDiscardSession(roomId: string): Promise { - await this.#call('invalidateGroupSession', { roomId }); + const invalidation = (this.#roomKeyInvalidations.get(roomId) ?? Promise.resolve()) + .catch(() => undefined) + .then(() => this.#call('invalidateGroupSession', { roomId })); + this.#roomKeyInvalidations.set(roomId, invalidation); + await invalidation; } async getEncryptionInfoForEvent(event: MatrixEvent): Promise { diff --git a/src/app/crypto/engineCrypto/membershipSendRace.test.ts b/src/app/crypto/engineCrypto/membershipSendRace.test.ts new file mode 100644 index 000000000..b056bece7 --- /dev/null +++ b/src/app/crypto/engineCrypto/membershipSendRace.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventType, KnownMembership } from '$types/matrix-sdk'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const deferred = () => Promise.withResolvers(); + +const memberEvent = () => + ({ + getType: () => EventType.RoomMember, + getStateKey: () => '@departed:e.org', + getContent: () => ({ membership: KnownMembership.Leave }), + getRoomId: () => '!room:e.org', + }) as unknown as MatrixEvent; + +const encryptedEvent = () => + ({ + getType: () => 'm.room.message', + getContent: () => ({ body: 'hello' }), + makeEncrypted: vi.fn<(...args: never[]) => void>(), + }) as unknown as MatrixEvent; + +const setup = () => { + let members = [{ userId: '@departed:e.org' }]; + const room = { + roomId: '!room:e.org', + getEncryptionTargetMembers: () => Promise.resolve(members), + getHistoryVisibility: () => 'shared', + getBlacklistUnverifiedDevices: () => false, + currentState: { getStateEvents: () => null }, + } as unknown as Room; + const authedRequest = vi.fn<(...args: never[]) => Promise>(async () => '{}'); + const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { + userId: '@me:e.org', + deviceId: 'D', + }); + return { + crypto, + room, + authedRequest, + replaceMembers: (next: string) => { + members = [{ userId: next }]; + }, + }; +}; + +describe('membership changes racing room sends', () => { + beforeEach(() => mockInvoke.mockReset()); + + it('reprepares with the current recipients after a leave during room-key delivery', async () => { + const shareDelivery = deferred(); + const invalidated = deferred(); + const { crypto, room, authedRequest, replaceMembers } = setup(); + let shares = 0; + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'shareRoomKey') { + shares += 1; + return [ + { + id: `share-${shares}`, + type: 3, + event_type: 'm.room.encrypted', + txn_id: 't', + body: '{}', + }, + ]; + } + if (method === 'invalidateGroupSession') return invalidated.promise; + if (method === 'encryptRoomEvent') return JSON.stringify({ session_id: `session-${shares}` }); + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; + return null; + }); + authedRequest.mockImplementation(async (_method, url) => + String(url).includes('/sendToDevice/') ? shareDelivery.promise : '{}' + ); + + const event = encryptedEvent(); + const send = crypto.encryptEvent(event, room); + await vi.waitFor(() => + expect( + authedRequest.mock.calls.some(([, url]) => String(url).includes('/sendToDevice/')) + ).toBe(true) + ); + replaceMembers('@joined:e.org'); + crypto.onRoomStateEvent(memberEvent()); + await vi.waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith( + expect.anything(), + 'invalidateGroupSession', + expect.anything() + ) + ); + shareDelivery.resolve('{}'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(event.makeEncrypted).not.toHaveBeenCalled(); + expect(mockInvoke.mock.calls.filter(([, method]) => method === 'shareRoomKey')).toHaveLength(1); + invalidated.resolve(); + await send; + + const users = mockInvoke.mock.calls + .filter(([, method]) => method === 'shareRoomKey') + .map(([, , args]) => (args as { users: string[] }).users); + expect(users).toEqual([['@departed:e.org'], ['@joined:e.org']]); + expect(event.makeEncrypted).toHaveBeenCalledTimes(1); + }); + + it('discards ciphertext produced while invalidation is pending', async () => { + const oldCiphertext = deferred(); + const invalidated = deferred(); + const { crypto, room } = setup(); + let encryptions = 0; + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'invalidateGroupSession') return invalidated.promise; + if (method === 'encryptRoomEvent') { + encryptions += 1; + return encryptions === 1 ? oldCiphertext.promise : JSON.stringify({ session_id: 'new' }); + } + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; + return null; + }); + + const event = encryptedEvent(); + const send = crypto.encryptEvent(event, room); + await vi.waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith( + expect.anything(), + 'encryptRoomEvent', + expect.anything() + ) + ); + crypto.onRoomStateEvent(memberEvent()); + await vi.waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith( + expect.anything(), + 'invalidateGroupSession', + expect.anything() + ) + ); + oldCiphertext.resolve(JSON.stringify({ session_id: 'old' })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(event.makeEncrypted).not.toHaveBeenCalled(); + expect(encryptions).toBe(1); + invalidated.resolve(); + await send; + + expect(encryptions).toBe(2); + expect(event.makeEncrypted).toHaveBeenCalledTimes(1); + expect(event.makeEncrypted).toHaveBeenCalledWith( + 'm.room.encrypted', + { session_id: 'new' }, + 'curve', + 'ed' + ); + }); + + it('discards ciphertext when membership changes while fetching identity keys', async () => { + const oldKeys = deferred<{ ed25519: string; curve25519: string }>(); + const invalidated = deferred(); + const { crypto, room } = setup(); + let encryptions = 0; + let keyRequests = 0; + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'invalidateGroupSession') return invalidated.promise; + if (method === 'encryptRoomEvent') { + encryptions += 1; + return JSON.stringify({ session_id: encryptions === 1 ? 'old' : 'new' }); + } + if (method === 'identityKeys') { + keyRequests += 1; + return keyRequests === 1 ? oldKeys.promise : { ed25519: 'ed', curve25519: 'curve' }; + } + return null; + }); + + const event = encryptedEvent(); + const send = crypto.encryptEvent(event, room); + await vi.waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith(expect.anything(), 'identityKeys', expect.anything()) + ); + crypto.onRoomStateEvent(memberEvent()); + await vi.waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith( + expect.anything(), + 'invalidateGroupSession', + expect.anything() + ) + ); + oldKeys.resolve({ ed25519: 'old-ed', curve25519: 'old-curve' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(event.makeEncrypted).not.toHaveBeenCalled(); + expect(encryptions).toBe(1); + invalidated.resolve(); + await send; + + expect(event.makeEncrypted).toHaveBeenCalledWith( + 'm.room.encrypted', + { session_id: 'new' }, + 'curve', + 'ed' + ); + expect(event.makeEncrypted).toHaveBeenCalledTimes(1); + }); + + it('propagates a failed invalidation', async () => { + const { crypto, room } = setup(); + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'invalidateGroupSession') throw new Error('invalidation failed'); + return null; + }); + + await expect(crypto.forceDiscardSession('!room:e.org')).rejects.toThrow('invalidation failed'); + const event = encryptedEvent(); + await expect(crypto.encryptEvent(event, room)).rejects.toThrow('invalidation failed'); + expect(event.makeEncrypted).not.toHaveBeenCalled(); + }); + + it('serializes consecutive invalidations and waits for the latest one before sending', async () => { + const first = deferred(); + const second = deferred(); + const { crypto, room } = setup(); + let invalidations = 0; + mockInvoke.mockImplementation(async (_identity, method) => { + if (method === 'invalidateGroupSession') { + invalidations += 1; + return invalidations === 1 ? first.promise : second.promise; + } + if (method === 'encryptRoomEvent') return '{}'; + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; + return null; + }); + + const firstDiscard = crypto.forceDiscardSession(room.roomId); + const secondDiscard = crypto.forceDiscardSession(room.roomId); + await vi.waitFor(() => expect(invalidations).toBe(1)); + first.resolve(); + await vi.waitFor(() => expect(invalidations).toBe(2)); + + const event = encryptedEvent(); + const send = crypto.encryptEvent(event, room); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') + ).toHaveLength(0); + second.resolve(); + await Promise.all([firstDiscard, secondDiscard, send]); + expect(event.makeEncrypted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/crypto/engineCrypto/prepareRoomKey.test.ts b/src/app/crypto/engineCrypto/prepareRoomKey.test.ts new file mode 100644 index 000000000..a1b8c088c --- /dev/null +++ b/src/app/crypto/engineCrypto/prepareRoomKey.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { engineInvoke } from '../olmMachine/engineInvoke'; +import { EngineCrypto } from './EngineCrypto'; + +vi.mock('../olmMachine/engineInvoke', () => ({ + engineInvoke: vi.fn<(...args: never[]) => Promise>(), +})); + +const mockInvoke = vi.mocked(engineInvoke); + +const room = { + roomId: '!room:e.org', + getEncryptionTargetMembers: async () => [{ userId: '@a:e.org' }], + getHistoryVisibility: () => 'shared', + getBlacklistUnverifiedDevices: () => false, + currentState: { getStateEvents: () => null }, +} as unknown as Room; + +const event = () => + ({ + getType: () => 'm.room.message', + getContent: () => ({ body: 'hello' }), + makeEncrypted: vi.fn<() => void>(), + }) as unknown as MatrixEvent; + +describe('room-key preparation', () => { + afterEach(() => { + vi.useRealTimers(); + mockInvoke.mockReset(); + }); + + it('shares room keys during preparation instead of delaying the send', async () => { + vi.useFakeTimers(); + let shareAcknowledged = false; + const share = { + id: 'share', + type: 3, + event_type: 'm.room.encrypted', + txn_id: 'share', + body: '{}', + }; + const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { + if (String(url).startsWith('/_matrix/client/v3/sendToDevice/')) { + return new Promise((resolve) => setTimeout(() => resolve('{}'), 20_000)); + } + return '{}'; + }); + mockInvoke.mockImplementation(async (_identity, method, args) => { + if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; + if (method === 'shareRoomKey') return shareAcknowledged ? [] : [share]; + if (method === 'markRequestAsSent' && (args as { requestId: string }).requestId === 'share') { + shareAcknowledged = true; + } + if (method === 'encryptRoomEvent') return '{}'; + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; + return null; + }); + + const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { + userId: '@me:e.org', + deviceId: 'D', + }); + crypto.prepareToEncrypt(room); + await vi.runAllTimersAsync(); + + const started = Date.now(); + const send = crypto.encryptEvent(event(), room).then(() => Date.now() - started); + await vi.runAllTimersAsync(); + + expect(await send).toBe(0); + expect( + authedRequest.mock.calls.filter(([, url]) => String(url).includes('/sendToDevice/')) + ).toHaveLength(1); + expect(mockInvoke.mock.calls.filter(([, method]) => method === 'shareRoomKey')).toHaveLength(2); + }); + + it('keeps a concurrent send behind room-key sharing and its acknowledgement', async () => { + vi.useFakeTimers(); + const shareResponse = Promise.withResolvers(); + const shareAcknowledged = Promise.withResolvers(); + const authedRequest = vi.fn<(...args: never[]) => Promise>(async (_method, url) => { + if (String(url).startsWith('/_matrix/client/v3/sendToDevice/')) return shareResponse.promise; + return '{}'; + }); + mockInvoke.mockImplementation(async (_identity, method, args) => { + if (method === 'queryKeysForUsers') return { id: 'query', type: 1, body: '{}' }; + if (method === 'shareRoomKey') + return [ + { id: 'share', type: 3, event_type: 'm.room.encrypted', txn_id: 'share', body: '{}' }, + ]; + if (method === 'markRequestAsSent' && (args as { requestId: string }).requestId === 'share') + await shareAcknowledged.promise; + if (method === 'encryptRoomEvent') return '{}'; + if (method === 'identityKeys') return { ed25519: 'ed', curve25519: 'curve' }; + return null; + }); + + const crypto = new EngineCrypto({ http: { authedRequest } } as unknown as MatrixClient, { + userId: '@me:e.org', + deviceId: 'D', + }); + crypto.prepareToEncrypt(room); + const send = crypto.encryptEvent(event(), room); + try { + await vi.waitFor(() => + expect(authedRequest).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('/sendToDevice/'), + expect.anything(), + expect.anything(), + expect.anything() + ) + ); + expect( + mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') + ).toHaveLength(0); + shareResponse.resolve('{}'); + await vi.waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith( + expect.anything(), + 'markRequestAsSent', + expect.objectContaining({ requestId: 'share' }) + ) + ); + expect( + mockInvoke.mock.calls.filter(([, method]) => method === 'encryptRoomEvent') + ).toHaveLength(0); + shareAcknowledged.resolve(); + await send; + } finally { + shareResponse.resolve('{}'); + shareAcknowledged.resolve(); + await send; + } + }); +});