Skip to content

Commit 2953e0b

Browse files
committed
add some additional test coverage
1 parent 7916588 commit 2953e0b

11 files changed

Lines changed: 558 additions & 15 deletions

File tree

packages/backend/src/nest/auth/sigchain.service.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { LocalDbModule } from '../local-db/local-db.module'
77
import { TestModule } from '../common/test.module'
88
import { SigChainModule } from './sigchain.service.module'
99
import { SigChain } from './sigchain'
10+
import { SocketEvents } from '@quiet/types'
11+
import waitForExpect from 'wait-for-expect'
1012

1113
const logger = createLogger('auth:sigchainManager.spec')
1214

@@ -128,4 +130,65 @@ describe('SigChainService - listener lifecycle', () => {
128130
expect(chainA.listenerCount('updated')).toBe(1)
129131
expect(chainB.listenerCount('updated')).toBe(0)
130132
})
133+
134+
it('does not emit iOS-native key or device events on non-ios platforms', async () => {
135+
const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit')
136+
137+
await sigChainService.createChain('desktopOnly', 'alice', true)
138+
139+
expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)).toHaveLength(0)
140+
expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.DEVICE_CREDENTIALS_UPDATED)).toHaveLength(0)
141+
})
142+
143+
it('emits new keys to iOS once and does not resend already-stored keys', async () => {
144+
const originalPlatform = process.platform
145+
Object.defineProperty(process, 'platform', { value: 'ios' })
146+
147+
try {
148+
const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit')
149+
const chain = await sigChainService.createChain('iosKeys', 'alice', true)
150+
const teamId = chain.team!.id
151+
152+
await waitForExpect(async () => {
153+
const keyCalls = emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)
154+
expect(keyCalls).toHaveLength(1)
155+
expect((keyCalls[0][1] as { keys: unknown[] }).keys.length).toBeGreaterThan(0)
156+
const storedKeys = await localDbService.getKeysStoredInKeychain(teamId)
157+
expect(storedKeys).toHaveLength((keyCalls[0][1] as { keys: unknown[] }).keys.length)
158+
})
159+
160+
const storedKeysAfterFirstUpdate = await localDbService.getKeysStoredInKeychain(teamId)
161+
162+
chain.emit('updated')
163+
164+
await new Promise(resolve => setTimeout(resolve, 25))
165+
166+
expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)).toHaveLength(1)
167+
expect(await localDbService.getKeysStoredInKeychain(teamId)).toEqual(storedKeysAfterFirstUpdate)
168+
} finally {
169+
Object.defineProperty(process, 'platform', { value: originalPlatform })
170+
}
171+
})
172+
173+
it('emits device credentials for the NSE on ios', async () => {
174+
const originalPlatform = process.platform
175+
Object.defineProperty(process, 'platform', { value: 'ios' })
176+
177+
try {
178+
const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit')
179+
const chain = await sigChainService.createChain('iosDeviceCredentials', 'alice', true)
180+
181+
await waitForExpect(() => {
182+
const deviceCalls = emitSpy.mock.calls.filter(([event]) => event === SocketEvents.DEVICE_CREDENTIALS_UPDATED)
183+
expect(deviceCalls).toHaveLength(1)
184+
expect(deviceCalls[0][1]).toEqual({
185+
deviceId: chain.device.deviceId,
186+
teamId: chain.team!.id,
187+
signingPrivateKey: chain.device.keys.signature.secretKey,
188+
})
189+
})
190+
} finally {
191+
Object.defineProperty(process, 'platform', { value: originalPlatform })
192+
}
193+
})
131194
})

packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,68 @@ describe('ConnectionsManagerService', () => {
166166
}
167167
})
168168

169+
it('falls back to the stored community QSS endpoint when no authoritative endpoint is available', async () => {
170+
const originalPlatform = process.platform
171+
Object.defineProperty(process, 'platform', {
172+
value: 'ios',
173+
})
174+
175+
try {
176+
await localDbService.setCommunity({
177+
...community,
178+
teamId: 'team-id',
179+
qssEndpoint: 'ws://community.example/ws',
180+
})
181+
await localDbService.setCurrentCommunityId(community.id)
182+
183+
qssService._qssEndpoint = undefined as any
184+
185+
const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit')
186+
187+
await (connectionsManagerService as any).emitNseQssUrl()
188+
189+
expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, {
190+
teamId: 'team-id',
191+
qssUrl: 'http://community.example/ws',
192+
})
193+
} finally {
194+
Object.defineProperty(process, 'platform', {
195+
value: originalPlatform,
196+
})
197+
}
198+
})
199+
200+
it('skips NSE QSS URL emission when no valid ws or wss endpoint can be derived', async () => {
201+
const originalPlatform = process.platform
202+
Object.defineProperty(process, 'platform', {
203+
value: 'ios',
204+
})
205+
206+
try {
207+
await localDbService.setCommunity({
208+
...community,
209+
teamId: 'team-id',
210+
qssEndpoint: 'https://community.example/api',
211+
})
212+
await localDbService.setCurrentCommunityId(community.id)
213+
214+
qssService._qssEndpoint = 'https://authoritative.example/api'
215+
216+
const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit')
217+
218+
await (connectionsManagerService as any).emitNseQssUrl()
219+
220+
expect(emitSpy).not.toHaveBeenCalledWith(
221+
SocketEvents.NSE_QSS_URL_UPDATED,
222+
expect.objectContaining({ teamId: 'team-id' })
223+
)
224+
} finally {
225+
Object.defineProperty(process, 'platform', {
226+
value: originalPlatform,
227+
})
228+
}
229+
})
230+
169231
it('pauses and resumes qss alongside the mobile lifecycle', async () => {
170232
const closeSocketSpy = jest.spyOn(connectionsManagerService, 'closeSocket').mockResolvedValue()
171233
const openSocketSpy = jest.spyOn(connectionsManagerService, 'openSocket').mockResolvedValue()

packages/backend/src/nest/qps/qps.service.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,4 +428,39 @@ describe('QPSService', () => {
428428
expect(sendBatchPushSpy).toHaveBeenCalledWith(TEAM_ID)
429429
})
430430
})
431+
432+
describe('sendPush', () => {
433+
beforeEach(() => {
434+
qssClient.connected = true
435+
qssClient.sendMessage.mockResolvedValue(pushSuccessResponse)
436+
})
437+
438+
it('strips qssUrl from single push data before sending to QPS', async () => {
439+
await qpsService.sendPush('ucan-user-a', 'title', 'body', {
440+
cid: 'cid-1',
441+
qssUrl: 'https://untrusted.example',
442+
})
443+
444+
expect(qssClient.sendMessage).toHaveBeenCalledWith(
445+
WebsocketEvents.SEND_PUSH,
446+
expect.objectContaining({
447+
payload: {
448+
ucan: 'ucan-user-a',
449+
title: 'title',
450+
body: 'body',
451+
data: { cid: 'cid-1' },
452+
},
453+
}),
454+
true
455+
)
456+
})
457+
458+
it('skips single push when QSS is not connected', async () => {
459+
qssClient.connected = false
460+
461+
await qpsService.sendPush('ucan-user-a', 'title', 'body', { cid: 'cid-1' })
462+
463+
expect(qssClient.sendMessage).not.toHaveBeenCalled()
464+
})
465+
})
431466
})

packages/backend/src/nest/qss/qss.service.spec.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
QSSOperationResult,
2222
} from './qss.types'
2323
import { createLogger } from '../common/logger'
24-
import { Community, Identity } from '@quiet/types'
24+
import { Community, Identity, SocketEvents } from '@quiet/types'
2525
import { getReduxStoreFactory, prepareStore, Store } from '@quiet/state-manager'
2626
import { FactoryGirl } from 'factory-girl'
2727
import { DateTime } from 'luxon'
@@ -640,6 +640,7 @@ describe('QSSService', () => {
640640
expect(initStatusOrig.qssSetup).toBeTruthy()
641641
const syncSeq = 41
642642
await localDbService.setLastSyncSeq(sigchainService.team.id, 40)
643+
const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit')
643644

644645
mockedJoinStatus = jest.spyOn(qssService, 'joinStatus').mockReturnValue(JoinStatus.JOINED)
645646
mockedSendMessage = jest
@@ -708,6 +709,10 @@ describe('QSSService', () => {
708709
expect(result).toBe(true)
709710
expect(mockedSendMessage).toHaveBeenCalledTimes(1)
710711
expect(await localDbService.getLastSyncSeq(sigchainService.team.id)).toBe(syncSeq)
712+
expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_SYNC_SEQ_UPDATED, {
713+
teamId: sigchainService.team.id,
714+
lastSyncSeq: syncSeq,
715+
})
711716

712717
const pendingMessages = await localDbService.getPendingQssLogSyncMessages()
713718
expect(pendingMessages).toEqual({})
@@ -754,6 +759,131 @@ describe('QSSService', () => {
754759
})
755760
})
756761

762+
it(`reconciles by pull when a fanout arrives before a sync-seq baseline is established`, async () => {
763+
await initCommunity({ qssEnabled: true, qssSetup: true })
764+
const teamId = sigchainService.activeChain.team!.id
765+
const pullSpy = jest.spyOn(qssService as any, '_pullLatestLogEntriesForTeam').mockResolvedValue(undefined)
766+
767+
jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(true)
768+
769+
qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, {
770+
ts: DateTime.utc().toMillis(),
771+
status: CommunityOperationStatus.SUCCESS,
772+
payload: {
773+
teamId,
774+
hash: 'fanout-baseline-hash',
775+
hashedDbId: 'fanout-baseline-db-id',
776+
encEntry: {
777+
encrypted: {
778+
contents: new Uint8Array(),
779+
scope: {
780+
type: EncryptionScopeType.ROLE,
781+
name: RoleName.MEMBER,
782+
generation: 1,
783+
},
784+
},
785+
signature: {
786+
signature: 'fanout-baseline-sig' as Base58,
787+
author: { type: 'USER', name: 'fanout-user' } as any,
788+
},
789+
ts: DateTime.utc().toMillis(),
790+
userId: sigchainService.user.userId,
791+
teamId,
792+
},
793+
syncSeq: 1,
794+
},
795+
} satisfies LogEntrySyncMessage)
796+
797+
await waitForExpect(() => {
798+
expect(pullSpy).toHaveBeenCalledWith(teamId)
799+
})
800+
expect(await localDbService.getLastSyncSeq(teamId)).toBeNull()
801+
})
802+
803+
it(`reconciles by pull when a sync-seq gap is detected from fanout`, async () => {
804+
await initCommunity({ qssEnabled: true, qssSetup: true })
805+
const teamId = sigchainService.activeChain.team!.id
806+
await localDbService.setLastSyncSeq(teamId, 5)
807+
const pullSpy = jest.spyOn(qssService as any, '_pullLatestLogEntriesForTeam').mockResolvedValue(undefined)
808+
809+
jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(true)
810+
811+
qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, {
812+
ts: DateTime.utc().toMillis(),
813+
status: CommunityOperationStatus.SUCCESS,
814+
payload: {
815+
teamId,
816+
hash: 'fanout-gap-hash',
817+
hashedDbId: 'fanout-gap-db-id',
818+
encEntry: {
819+
encrypted: {
820+
contents: new Uint8Array(),
821+
scope: {
822+
type: EncryptionScopeType.ROLE,
823+
name: RoleName.MEMBER,
824+
generation: 1,
825+
},
826+
},
827+
signature: {
828+
signature: 'fanout-gap-sig' as Base58,
829+
author: { type: 'USER', name: 'fanout-user' } as any,
830+
},
831+
ts: DateTime.utc().toMillis(),
832+
userId: sigchainService.user.userId,
833+
teamId,
834+
},
835+
syncSeq: 7,
836+
},
837+
} satisfies LogEntrySyncMessage)
838+
839+
await waitForExpect(() => {
840+
expect(pullSpy).toHaveBeenCalledWith(teamId)
841+
})
842+
expect(await localDbService.getLastSyncSeq(teamId)).toBe(5)
843+
})
844+
845+
it(`reconciles by pull when fanout ingest fails even with a contiguous sync seq`, async () => {
846+
await initCommunity({ qssEnabled: true, qssSetup: true })
847+
const teamId = sigchainService.activeChain.team!.id
848+
await localDbService.setLastSyncSeq(teamId, 5)
849+
const pullSpy = jest.spyOn(qssService as any, '_pullLatestLogEntriesForTeam').mockResolvedValue(undefined)
850+
851+
jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(false)
852+
853+
qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, {
854+
ts: DateTime.utc().toMillis(),
855+
status: CommunityOperationStatus.SUCCESS,
856+
payload: {
857+
teamId,
858+
hash: 'fanout-failure-hash',
859+
hashedDbId: 'fanout-failure-db-id',
860+
encEntry: {
861+
encrypted: {
862+
contents: new Uint8Array(),
863+
scope: {
864+
type: EncryptionScopeType.ROLE,
865+
name: RoleName.MEMBER,
866+
generation: 1,
867+
},
868+
},
869+
signature: {
870+
signature: 'fanout-failure-sig' as Base58,
871+
author: { type: 'USER', name: 'fanout-user' } as any,
872+
},
873+
ts: DateTime.utc().toMillis(),
874+
userId: sigchainService.user.userId,
875+
teamId,
876+
},
877+
syncSeq: 6,
878+
},
879+
} satisfies LogEntrySyncMessage)
880+
881+
await waitForExpect(() => {
882+
expect(pullSpy).toHaveBeenCalledWith(teamId)
883+
})
884+
expect(await localDbService.getLastSyncSeq(teamId)).toBe(5)
885+
})
886+
757887
it(`fails to send log sync to QSS and writes pending message to local DB`, async () => {
758888
await initCommunity({ qssEnabled: true, qssSetup: true })
759889
const initStatusOrig = await qssService.getQssInitStatus()

packages/mobile/src/setupTests.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ jest.mock('react-native', () => {
4747
requestNotificationPermission: jest.fn(),
4848
checkNotificationPermission: jest.fn(),
4949
handleIncomingEvents: jest.fn(),
50+
saveKeysInKeychain: jest.fn(),
51+
saveDeviceCredentials: jest.fn(),
52+
saveUserMetadata: jest.fn(),
5053
saveNseQssUrl: jest.fn(),
5154
saveNseLastSyncSeq: jest.fn(),
5255
}

0 commit comments

Comments
 (0)