Skip to content

Commit 7067910

Browse files
authored
Feat/3083 store ucans (#3108)
* request permissions when ios app opens, detect changes to permission on app open * register when permission is granted and rely solely on event channels for payloads * simplify sagas, ensure event channels are set up first * update changelog * fix mobile unit tests * pass token to backend and scaffold registration * cache token until we are part of a community and connected to qss * update changelog * fix typo in changelog * remove caching of device token in redux because it is not needed * adjust mocks * try to get around ci weirdness * fix mobile tests * add qps consts to test module * fix state-manager test * remove http related deprecated code * fix merge conflict * implement storage * add todos for future work * return state * update test * skip test for device linking * test fix
1 parent 8cb7f1e commit 7067910

File tree

11 files changed

+530
-8
lines changed

11 files changed

+530
-8
lines changed

packages/backend/src/nest/qps/qps.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'
22
import { SocketModule } from '../socket/socket.module'
33
import { QSSModule } from '../qss/qss.module'
44
import { SigChainModule } from '../auth/sigchain.service.module'
5+
import { OrbitDbModule } from '../storage/orbitDb/orbitdb.module'
56
import { QPSService } from './qps.service'
67

78
@Module({
8-
imports: [SocketModule, QSSModule, SigChainModule],
9+
imports: [SocketModule, QSSModule, SigChainModule, OrbitDbModule],
910
providers: [QPSService],
1011
exports: [QPSService],
1112
})

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,21 @@ class MockQSSClient extends EventEmitter {
1818

1919
class MockSigChainService extends EventEmitter {
2020
activeChain: any = null
21+
user = { userId: 'test-user-id' }
2122
}
2223

2324
class MockSocketService extends EventEmitter {}
2425

26+
class MockNotificationTokensStore {
27+
addToken = jest.fn<any>()
28+
}
29+
2530
describe('QPSService', () => {
2631
let qpsService: QPSService
2732
let qssClient: MockQSSClient
2833
let sigChainService: MockSigChainService
2934
let socketService: MockSocketService
35+
let notificationTokensStore: MockNotificationTokensStore
3036

3137
const TOKEN = 'fake-device-token-abc123'
3238

@@ -50,12 +56,14 @@ describe('QPSService', () => {
5056
qssClient = new MockQSSClient()
5157
sigChainService = new MockSigChainService()
5258
socketService = new MockSocketService()
59+
notificationTokensStore = new MockNotificationTokensStore()
5360

5461
qpsService = new QPSService(
5562
true, // qpsAllowed
5663
socketService as any,
5764
qssClient as any,
58-
sigChainService as any
65+
sigChainService as any,
66+
notificationTokensStore as any
5967
)
6068

6169
qssClient.sendMessage.mockResolvedValue(successResponse)
@@ -81,9 +89,33 @@ describe('QPSService', () => {
8189
)
8290
})
8391

92+
it('stores UCAN in notification tokens store after successful registration', async () => {
93+
setReady()
94+
95+
await qpsService.register(TOKEN)
96+
97+
expect(notificationTokensStore.addToken).toHaveBeenCalledWith('test-user-id', 'test-ucan')
98+
})
99+
100+
it('still returns UCAN if storing in notification tokens store fails', async () => {
101+
setReady()
102+
notificationTokensStore.addToken.mockRejectedValueOnce(new Error('store not initialized'))
103+
104+
const result = await qpsService.register(TOKEN)
105+
106+
expect(result).toEqual({ ucan: 'test-ucan' })
107+
expect(notificationTokensStore.addToken).toHaveBeenCalledWith('test-user-id', 'test-ucan')
108+
})
109+
84110
it('returns null and does not send when disabled', async () => {
85111
// Create a disabled instance
86-
const disabled = new QPSService(false, socketService as any, qssClient as any, sigChainService as any)
112+
const disabled = new QPSService(
113+
false,
114+
socketService as any,
115+
qssClient as any,
116+
sigChainService as any,
117+
notificationTokensStore as any
118+
)
87119
setReady()
88120

89121
const result = await disabled.register(TOKEN)

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { QSSClient } from '../qss/qss.client'
99
import { CommunityOperationStatus, QSSEvents, WebsocketEvents } from '../qss/qss.types'
1010
import { SigChainService } from '../auth/sigchain.service'
1111
import { RoleName } from '../auth/services/roles/roles'
12+
import { NotificationTokensStore } from '../storage/notifications/notificationTokens.store'
1213

1314
const BUNDLE_ID = 'com.quietmobile'
1415

@@ -21,7 +22,8 @@ export class QPSService implements OnModuleInit {
2122
@Inject(QPS_ALLOWED) private readonly qpsAllowed: boolean,
2223
private readonly socketService: SocketService,
2324
private readonly qssClient: QSSClient,
24-
private readonly sigChainService: SigChainService
25+
private readonly sigChainService: SigChainService,
26+
private readonly notificationTokensStore: NotificationTokensStore
2527
) {}
2628

2729
public get enabled(): boolean {
@@ -99,6 +101,12 @@ export class QPSService implements OnModuleInit {
99101

100102
if (response?.status === CommunityOperationStatus.SUCCESS && response.payload?.ucan) {
101103
this.logger.info('QPS registration successful, received UCAN')
104+
try {
105+
const userId = this.sigChainService.user.userId
106+
await this.notificationTokensStore.addToken(userId, response.payload.ucan)
107+
} catch (err) {
108+
this.logger.error('Failed to store UCAN in notification tokens store', err)
109+
}
102110
return { ucan: response.payload.ucan }
103111
}
104112

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { jest } from '@jest/globals'
2+
import fs from 'fs'
3+
4+
import { PushNotificationTokens } from '@quiet/types'
5+
import { MAX_TOKENS_PER_USER, NotificationTokensStore } from './notificationTokens.store'
6+
import { Test, TestingModule } from '@nestjs/testing'
7+
import { createLogger } from '../../common/logger'
8+
import { SigChainService } from '../../auth/sigchain.service'
9+
import { TestModule } from '../../common/test.module'
10+
import { StorageModule } from '../storage.module'
11+
import { Libp2pModule } from '../../libp2p/libp2p.module'
12+
import { IpfsModule } from '../../ipfs/ipfs.module'
13+
import { SigChainModule } from '../../auth/sigchain.service.module'
14+
import { Libp2pService } from '../../libp2p/libp2p.service'
15+
import { IpfsService } from '../../ipfs/ipfs.service'
16+
import { OrbitDbService } from '../orbitDb/orbitDb.service'
17+
import { LocalDbService } from '../../local-db/local-db.service'
18+
import { libp2pInstanceParams } from '../../common/utils'
19+
import { TestConfig } from '../../const'
20+
import { LogEntry } from '@orbitdb/core'
21+
import { EncryptedAndSignedPayload } from '../../auth/services/crypto/types'
22+
23+
const logger = createLogger('notificationTokensStore:test')
24+
25+
describe('NotificationTokensStore', () => {
26+
let notificationTokensStore: NotificationTokensStore
27+
28+
let module: TestingModule
29+
let libp2pService: Libp2pService
30+
let ipfsService: IpfsService
31+
let orbitDbService: OrbitDbService
32+
let localDbService: LocalDbService
33+
let sigChainService: SigChainService
34+
let userId: string
35+
36+
beforeEach(async () => {
37+
jest.clearAllMocks()
38+
39+
module = await Test.createTestingModule({
40+
imports: [TestModule, StorageModule, Libp2pModule, IpfsModule, SigChainModule],
41+
}).compile()
42+
43+
sigChainService = await module.resolve(SigChainService)
44+
await sigChainService.createChain('test-community', 'alice', true)
45+
userId = sigChainService.getActiveChain().user.userId
46+
47+
libp2pService = await module.resolve(Libp2pService)
48+
const libp2pParams = await libp2pInstanceParams()
49+
await libp2pService.createInstance(libp2pParams)
50+
51+
ipfsService = await module.resolve(IpfsService)
52+
await ipfsService.createInstance()
53+
54+
orbitDbService = await module.resolve(OrbitDbService)
55+
await orbitDbService.create(ipfsService.ipfsInstance!)
56+
localDbService = await module.resolve(LocalDbService)
57+
58+
notificationTokensStore = await module.resolve(NotificationTokensStore)
59+
await notificationTokensStore.init()
60+
logger.info('Running test:', expect.getState().currentTestName)
61+
})
62+
63+
afterEach(async () => {
64+
await notificationTokensStore.close()
65+
await orbitDbService.stop()
66+
await ipfsService.stop()
67+
await libp2pService.close()
68+
await localDbService.close()
69+
if (fs.existsSync(TestConfig.ORBIT_DB_DIR)) {
70+
fs.rmSync(TestConfig.ORBIT_DB_DIR, { recursive: true })
71+
}
72+
})
73+
74+
test('should be defined', () => {
75+
expect(notificationTokensStore).toBeDefined()
76+
})
77+
78+
test('should set and get a notification token entry', async () => {
79+
const entry: PushNotificationTokens = { userId, tokens: ['ucan-1'] }
80+
const encrypted = await notificationTokensStore.setEntry(userId, entry)
81+
expect(encrypted).toBeDefined()
82+
expect(encrypted).not.toEqual(entry)
83+
84+
const result = await notificationTokensStore.getEntry(userId)
85+
expect(result).toEqual(entry)
86+
})
87+
88+
test('should get all entries', async () => {
89+
const entry: PushNotificationTokens = { userId, tokens: ['ucan-1'] }
90+
await notificationTokensStore.setEntry(userId, entry)
91+
92+
const results = await notificationTokensStore.getAllEntries()
93+
expect(results).toHaveLength(1)
94+
expect(results[0]).toEqual(entry)
95+
})
96+
97+
test('addToken creates new entry when none exists', async () => {
98+
await notificationTokensStore.addToken(userId, 'ucan-1')
99+
100+
const result = await notificationTokensStore.getEntry(userId)
101+
expect(result).toEqual({ userId, tokens: ['ucan-1'] })
102+
})
103+
104+
// NOTE: skipping until device linking added
105+
test.skip('addToken appends to existing entry', async () => {
106+
await notificationTokensStore.addToken(userId, 'ucan-1')
107+
await notificationTokensStore.addToken(userId, 'ucan-2')
108+
109+
const result = await notificationTokensStore.getEntry(userId)
110+
expect(result).toEqual({ userId, tokens: ['ucan-1', 'ucan-2'] })
111+
})
112+
113+
test('addToken deduplicates by exact string match', async () => {
114+
await notificationTokensStore.addToken(userId, 'ucan-1')
115+
await notificationTokensStore.addToken(userId, 'ucan-1')
116+
117+
const result = await notificationTokensStore.getEntry(userId)
118+
expect(result).toEqual({ userId, tokens: ['ucan-1'] })
119+
})
120+
121+
test('addToken evicts oldest tokens when exceeding max ', async () => {
122+
for (let i = 0; i < MAX_TOKENS_PER_USER; i++) {
123+
await notificationTokensStore.addToken(userId, `ucan-${i}`)
124+
}
125+
await notificationTokensStore.addToken(userId, 'ucan-new')
126+
127+
const result = await notificationTokensStore.getEntry(userId)
128+
expect(result.tokens).toHaveLength(MAX_TOKENS_PER_USER)
129+
expect(result.tokens[0]).toBe('ucan-new') // ucan-0 evicted
130+
})
131+
})
132+
133+
describe('NotificationTokensStore/validateEntry', () => {
134+
test('should reject entry if key does not match userId in payload or signature', async () => {
135+
const aliceUserId = 'aliceUserId'
136+
const bobUserId = 'bobUserId'
137+
const encPayload: any = {
138+
userId: aliceUserId,
139+
signature: { author: { name: aliceUserId } },
140+
encrypted: 'fake-encrypted',
141+
}
142+
const decEntry: PushNotificationTokens = { userId: aliceUserId, tokens: ['ucan-1'] }
143+
const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any)
144+
jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry)
145+
const entry = {
146+
hash: 'fakehash',
147+
payload: { key: bobUserId, op: 'PUT', value: encPayload },
148+
} as unknown as LogEntry<EncryptedAndSignedPayload>
149+
150+
const result = await store.validateEntry(entry)
151+
expect(result).toBe(false)
152+
})
153+
154+
test('should reject entry if tokens is not a string array', async () => {
155+
const aliceUserId = 'aliceUserId'
156+
const encPayload: any = {
157+
userId: aliceUserId,
158+
signature: { author: { name: aliceUserId } },
159+
encrypted: 'fake-encrypted',
160+
}
161+
const decEntry: any = { userId: aliceUserId, tokens: 'not-an-array' }
162+
const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any)
163+
jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry)
164+
const entry = {
165+
hash: 'fakehash',
166+
payload: { key: aliceUserId, op: 'PUT', value: encPayload },
167+
} as unknown as LogEntry<EncryptedAndSignedPayload>
168+
169+
const result = await store.validateEntry(entry)
170+
expect(result).toBe(false)
171+
})
172+
173+
test('should reject entry if tokens exceeds max per user', async () => {
174+
const aliceUserId = 'aliceUserId'
175+
const encPayload: any = {
176+
userId: aliceUserId,
177+
signature: { author: { name: aliceUserId } },
178+
encrypted: 'fake-encrypted',
179+
}
180+
const decEntry: PushNotificationTokens = {
181+
userId: aliceUserId,
182+
tokens: Array.from({ length: 11 }, (_, i) => `ucan-${i}`),
183+
}
184+
const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any)
185+
jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry)
186+
const entry = {
187+
hash: 'fakehash',
188+
payload: { key: aliceUserId, op: 'PUT', value: encPayload },
189+
} as unknown as LogEntry<EncryptedAndSignedPayload>
190+
191+
const result = await store.validateEntry(entry)
192+
expect(result).toBe(false)
193+
})
194+
195+
test('should reject entry if decryption fails', async () => {
196+
const aliceUserId = 'aliceUserId'
197+
const encPayload: any = {
198+
userId: aliceUserId,
199+
signature: { author: { name: aliceUserId } },
200+
encrypted: 'fake-encrypted',
201+
}
202+
const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any)
203+
jest.spyOn(store, 'decryptEntry').mockRejectedValue(new Error('decryption failed'))
204+
const entry = {
205+
hash: 'fakehash',
206+
payload: { key: aliceUserId, op: 'PUT', value: encPayload },
207+
} as unknown as LogEntry<EncryptedAndSignedPayload>
208+
209+
const result = await store.validateEntry(entry)
210+
expect(result).toBe(false)
211+
})
212+
})

0 commit comments

Comments
 (0)