|
| 1 | +/** |
| 2 | + * XMPPClient E2EE integration tests — retryPendingDecrypts(). |
| 3 | + * |
| 4 | + * Regression guard for: self-outgoing messages stored with encryptedPayload |
| 5 | + * (sent carbons or MAM self-replays received before the key was unlocked) |
| 6 | + * were permanently undecryptable because retryDecryptSingle did not pass |
| 7 | + * isSelfOutgoing: true when senderJid === ownBareJid. Without that flag the |
| 8 | + * OpenPGP plugin's signcrypt reflection check compared the envelope's <to/> |
| 9 | + * addressee against our own JID, but the envelope names the conversation |
| 10 | + * peer — so it threw 'envelope-reflection' and the message stayed broken. |
| 11 | + */ |
| 12 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' |
| 13 | +import { localStorageMock } from './sideEffects.testHelpers' |
| 14 | + |
| 15 | +// chatStore uses persist middleware — needs localStorage before any store import |
| 16 | +Object.defineProperty(globalThis, 'localStorage', { |
| 17 | + value: localStorageMock, |
| 18 | + writable: true, |
| 19 | +}) |
| 20 | + |
| 21 | +// Prevent IndexedDB operations triggered by chatStore.addMessage / updateMessage |
| 22 | +vi.mock('../utils/messageCache', () => ({ |
| 23 | + saveMessage: vi.fn().mockResolvedValue(undefined), |
| 24 | + saveMessages: vi.fn().mockResolvedValue(undefined), |
| 25 | + getMessages: vi.fn().mockResolvedValue([]), |
| 26 | + getMessage: vi.fn().mockResolvedValue(null), |
| 27 | + getMessageByStanzaId: vi.fn().mockResolvedValue(null), |
| 28 | + updateMessage: vi.fn().mockResolvedValue(undefined), |
| 29 | + deleteMessage: vi.fn().mockResolvedValue(undefined), |
| 30 | + deleteConversationMessages: vi.fn().mockResolvedValue(undefined), |
| 31 | + clearAllMessages: vi.fn().mockResolvedValue(undefined), |
| 32 | + isMessageCacheAvailable: vi.fn().mockReturnValue(false), |
| 33 | + getOldestMessageTimestamp: vi.fn().mockResolvedValue(null), |
| 34 | + getMessageCount: vi.fn().mockResolvedValue(0), |
| 35 | + saveRoomMessage: vi.fn().mockResolvedValue(undefined), |
| 36 | + saveRoomMessages: vi.fn().mockResolvedValue(undefined), |
| 37 | + getRoomMessages: vi.fn().mockResolvedValue([]), |
| 38 | + getRoomMessage: vi.fn().mockResolvedValue(null), |
| 39 | + getRoomMessageByStanzaId: vi.fn().mockResolvedValue(null), |
| 40 | + updateRoomMessage: vi.fn().mockResolvedValue(undefined), |
| 41 | + deleteRoomMessage: vi.fn().mockResolvedValue(undefined), |
| 42 | + deleteRoomMessages: vi.fn().mockResolvedValue(undefined), |
| 43 | + getRoomMessageCount: vi.fn().mockResolvedValue(0), |
| 44 | +})) |
| 45 | + |
| 46 | +import { XMPPClient } from './XMPPClient' |
| 47 | +import { chatStore } from '../stores/chatStore' |
| 48 | +import { |
| 49 | + E2EEManager, |
| 50 | + InMemoryStorageBackend, |
| 51 | + type XMPPPrimitives, |
| 52 | +} from './e2ee' |
| 53 | +import { DummyPlaintextPlugin } from './e2ee/DummyPlaintextPlugin' |
| 54 | +import { _resetStorageScopeForTesting } from '../utils/storageScope' |
| 55 | + |
| 56 | +function stubXmppPrimitives(): XMPPPrimitives { |
| 57 | + return { |
| 58 | + sendStanza: async () => {}, |
| 59 | + queryDisco: async () => ({ features: [], identities: [] }), |
| 60 | + publishPEP: async () => {}, |
| 61 | + retractPEP: async () => {}, |
| 62 | + deletePEP: async () => {}, |
| 63 | + queryPEP: async () => [], |
| 64 | + subscribePEP: () => ({ unsubscribe: () => {} }), |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +async function makeManagerWithDummyPlugin(selfJid: string): Promise<E2EEManager> { |
| 69 | + const manager = new E2EEManager({ |
| 70 | + storage: new InMemoryStorageBackend(), |
| 71 | + xmpp: stubXmppPrimitives(), |
| 72 | + account: { jid: selfJid }, |
| 73 | + }) |
| 74 | + await manager.register(new DummyPlaintextPlugin()) |
| 75 | + return manager |
| 76 | +} |
| 77 | + |
| 78 | +// DummyPlaintextPlugin serialises the payload as: |
| 79 | +// <plain xmlns='urn:fluux:e2ee-dummy:0'>base64(plaintext)</plain> |
| 80 | +// This is the encryptedPayload string that retryPendingDecrypts() will parse. |
| 81 | +const DUMMY_PAYLOAD_XML = `<plain xmlns="urn:fluux:e2ee-dummy:0">aGVsbG8=</plain>` // base64("hello") |
| 82 | + |
| 83 | +describe('XMPPClient.retryPendingDecrypts()', () => { |
| 84 | + let xmppClient: XMPPClient |
| 85 | + let manager: E2EEManager |
| 86 | + |
| 87 | + beforeEach(async () => { |
| 88 | + _resetStorageScopeForTesting() |
| 89 | + chatStore.getState().reset() |
| 90 | + manager = await makeManagerWithDummyPlugin('me@example.com') |
| 91 | + xmppClient = new XMPPClient({ debug: false }) |
| 92 | + // e2ee is a public field, normally set in handleConnectionSuccess |
| 93 | + xmppClient.e2ee = manager |
| 94 | + // currentJid is protected; cast to inject without a full connection |
| 95 | + ;(xmppClient as unknown as { currentJid: string }).currentJid = 'me@example.com/web' |
| 96 | + }) |
| 97 | + |
| 98 | + afterEach(() => { |
| 99 | + vi.clearAllMocks() |
| 100 | + chatStore.getState().reset() |
| 101 | + }) |
| 102 | + |
| 103 | + describe('isSelfOutgoing flag propagation', () => { |
| 104 | + it('passes isSelfOutgoing=true to the plugin when the sender equals our own bare JID', async () => { |
| 105 | + // A message where from === own bare JID represents a sent carbon or MAM |
| 106 | + // self-replay: our other device sent it and the server copied it to us. |
| 107 | + chatStore.getState().addConversation({ |
| 108 | + id: 'bob@example.com', |
| 109 | + name: 'Bob', |
| 110 | + type: 'chat', |
| 111 | + lastMessage: undefined, |
| 112 | + unreadCount: 0, |
| 113 | + }) |
| 114 | + chatStore.getState().addMessage({ |
| 115 | + type: 'chat', |
| 116 | + id: 'msg-self-outgoing', |
| 117 | + conversationId: 'bob@example.com', |
| 118 | + from: 'me@example.com', // own bare JID → self-outgoing |
| 119 | + body: '[dummy-plaintext payload]', |
| 120 | + timestamp: new Date(), |
| 121 | + isOutgoing: true, |
| 122 | + encryptedPayload: DUMMY_PAYLOAD_XML, |
| 123 | + }) |
| 124 | + |
| 125 | + const decryptSpy = vi.spyOn(manager, 'decryptArchive') |
| 126 | + await xmppClient.retryPendingDecrypts() |
| 127 | + |
| 128 | + // decryptArchive must have been called exactly once. |
| 129 | + expect(decryptSpy).toHaveBeenCalledTimes(1) |
| 130 | + |
| 131 | + // The plugin must receive isSelfOutgoing=true so it inverts its |
| 132 | + // reflection check (signcrypt <to/> names the peer, not us). |
| 133 | + const [, , context] = decryptSpy.mock.calls[0] |
| 134 | + expect(context?.isSelfOutgoing).toBe(true) |
| 135 | + }) |
| 136 | + |
| 137 | + it('does not set isSelfOutgoing when the sender is the conversation peer', async () => { |
| 138 | + chatStore.getState().addConversation({ |
| 139 | + id: 'bob@example.com', |
| 140 | + name: 'Bob', |
| 141 | + type: 'chat', |
| 142 | + lastMessage: undefined, |
| 143 | + unreadCount: 0, |
| 144 | + }) |
| 145 | + chatStore.getState().addMessage({ |
| 146 | + type: 'chat', |
| 147 | + id: 'msg-inbound', |
| 148 | + conversationId: 'bob@example.com', |
| 149 | + from: 'bob@example.com', // peer JID → normal inbound |
| 150 | + body: '[dummy-plaintext payload]', |
| 151 | + timestamp: new Date(), |
| 152 | + isOutgoing: false, |
| 153 | + encryptedPayload: DUMMY_PAYLOAD_XML, |
| 154 | + }) |
| 155 | + |
| 156 | + const decryptSpy = vi.spyOn(manager, 'decryptArchive') |
| 157 | + await xmppClient.retryPendingDecrypts() |
| 158 | + |
| 159 | + expect(decryptSpy).toHaveBeenCalledTimes(1) |
| 160 | + const [, , context] = decryptSpy.mock.calls[0] |
| 161 | + expect(context?.isSelfOutgoing).not.toBe(true) |
| 162 | + }) |
| 163 | + |
| 164 | + it('updates the message body and clears encryptedPayload on successful retry', async () => { |
| 165 | + // The DummyPlaintextPlugin always returns trust:'untrusted', which |
| 166 | + // triggers needsDeferredVerification and prevents count from being |
| 167 | + // incremented. To test the end-to-end store update, mock decryptArchive |
| 168 | + // to return trust:'verified' so the message is committed to the store. |
| 169 | + vi.spyOn(manager, 'decryptArchive').mockResolvedValue({ |
| 170 | + plaintext: new TextEncoder().encode('hello'), |
| 171 | + senderDevice: { jid: 'me@example.com', deviceId: 'test' }, |
| 172 | + securityContext: { protocolId: 'dummy-plaintext', trust: 'verified' }, |
| 173 | + }) |
| 174 | + |
| 175 | + chatStore.getState().addConversation({ |
| 176 | + id: 'bob@example.com', |
| 177 | + name: 'Bob', |
| 178 | + type: 'chat', |
| 179 | + lastMessage: undefined, |
| 180 | + unreadCount: 0, |
| 181 | + }) |
| 182 | + chatStore.getState().addMessage({ |
| 183 | + type: 'chat', |
| 184 | + id: 'msg-body-check', |
| 185 | + conversationId: 'bob@example.com', |
| 186 | + from: 'me@example.com', |
| 187 | + body: '[dummy-plaintext payload]', |
| 188 | + timestamp: new Date(), |
| 189 | + isOutgoing: true, |
| 190 | + encryptedPayload: DUMMY_PAYLOAD_XML, |
| 191 | + }) |
| 192 | + |
| 193 | + const count = await xmppClient.retryPendingDecrypts() |
| 194 | + |
| 195 | + expect(count).toBe(1) |
| 196 | + // After retry, the message in the store must have the plaintext body |
| 197 | + // and no encryptedPayload stash. |
| 198 | + const messages = chatStore.getState().messages.get('bob@example.com') ?? [] |
| 199 | + const msg = messages.find((m) => m.id === 'msg-body-check') |
| 200 | + expect(msg?.body).toBe('hello') |
| 201 | + expect(msg?.encryptedPayload).toBeUndefined() |
| 202 | + }) |
| 203 | + }) |
| 204 | +}) |
0 commit comments