Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 166 additions & 1 deletion apps/chat-service/src/__tests__/routes/retrieveMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {runPromiseInMockedEnvironment} from '../utils/runPromiseInMockedEnvironm

import {SqlClient} from '@effect/sql'
import {generatePrivateKey} from '@vexl-next/cryptography/src/KeyHolder'
import {type MessageCypher} from '@vexl-next/domain/src/general/messaging'
import {
MessageCypher,
MessageType,
} from '@vexl-next/domain/src/general/messaging'
import {CommonHeaders} from '@vexl-next/rest-api/src/commonHeaders'
import {type SendMessageRequest} from '@vexl-next/rest-api/src/services/chat/contracts'
import {InboxDoesNotExistError} from '@vexl-next/rest-api/src/services/contact/contracts'
Expand Down Expand Up @@ -199,6 +202,168 @@ describe('Retrieve messages', () => {
)
})

it('Does not mark messages as pulled when markAsPulled is false', async () => {
await runPromiseInMockedEnvironment(
Effect.gen(function* (_) {
const client = yield* _(NodeTestingApp)

const messageToSend = (yield* _(
user2.inbox1.addChallenge({
message: Schema.decodeSync(MessageCypher)(
'messageRetrievedWithoutPull'
),
messageType: Schema.decodeSync(MessageType)('MESSAGE'),
receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64,
})
)) satisfies SendMessageRequest

yield* _(setAuthHeaders(user2.authHeaders))
yield* _(
client.Messages.sendMessage({
payload: messageToSend,
})
)

yield* _(setAuthHeaders(user1.authHeaders))
const messagesWithoutPulling = yield* _(
client.Messages.retrieveMessages({
payload: yield* _(
user1.addChallengeForMainInbox({markAsPulled: false})
),
headers: Schema.decodeSync(CommonHeaders)({
'user-agent': 'Vexl/1 (1.0.0) ANDROID',
}),
})
)
expect(
messagesWithoutPulling.messages.map((one) => one.message)
).toContain('messageRetrievedWithoutPull')

yield* _(
client.Inboxes.deletePulledMessages({
payload: yield* _(user1.addChallengeForMainInbox({})),
})
)

// Message was not marked as pulled so it must still be retrievable
const messagesAfterDelete = yield* _(
client.Messages.retrieveMessages({
payload: yield* _(user1.addChallengeForMainInbox({})),
headers: Schema.decodeSync(CommonHeaders)({
'user-agent': 'Vexl/1 (1.0.0) ANDROID',
}),
})
)
expect(
messagesAfterDelete.messages.map((one) => one.message)
).toContain('messageRetrievedWithoutPull')

// The previous retrieve marked it as pulled so now it gets deleted
yield* _(
client.Inboxes.deletePulledMessages({
payload: yield* _(user1.addChallengeForMainInbox({})),
})
)
const messagesAfterSecondDelete = yield* _(
client.Messages.retrieveMessages({
payload: yield* _(user1.addChallengeForMainInbox({})),
headers: Schema.decodeSync(CommonHeaders)({
'user-agent': 'Vexl/1 (1.0.0) ANDROID',
}),
})
)
expect(
messagesAfterSecondDelete.messages.map((one) => one.message)
).not.toContain('messageRetrievedWithoutPull')
})
)
})

it('Read-only retrieve (markAsPulled false) works without a Vexl user-agent and does not touch inbox metadata', async () => {
// Mirrors the iOS notification service extension: it sends no Vexl
// user-agent and no client-version header (clientVersionOrNone resolves
// to Option.none()), so the handler must not run updateInboxMetadata -
// otherwise it would write NULL into the NOT NULL client_version column
// and the whole request would fail with a 500.
await runPromiseInMockedEnvironment(
Effect.gen(function* (_) {
const client = yield* _(NodeTestingApp)

const messageToSend = (yield* _(
user2.inbox1.addChallenge({
message: Schema.decodeSync(MessageCypher)('messageRetrievedByNse'),
messageType: Schema.decodeSync(MessageType)('MESSAGE'),
receiverPublicKey: user1.mainKeyPair.publicKeyPemBase64,
})
)) satisfies SendMessageRequest

yield* _(setAuthHeaders(user2.authHeaders))
yield* _(
client.Messages.sendMessage({
payload: messageToSend,
})
)

const sql = yield* _(SqlClient.SqlClient)
const inboxHash = yield* _(
hashPublicKey(user1.mainKeyPair.publicKeyPemBase64)
)

// Snapshot the NOT NULL client_version before the read-only retrieve so
// we can prove updateInboxMetadata did not run: it would overwrite this
// value, and since the NSE sends no client-version header it would write
// NULL and fail the request. Alias the column so the assertion does not
// silently depend on the result-name transform config.
const clientVersionBefore = (yield* _(sql`
SELECT
client_version AS "clientVersion"
FROM
inbox
WHERE
public_key = ${inboxHash}
`))[0]?.clientVersion
expect(typeof clientVersionBefore).toBe('number')

yield* _(setAuthHeaders(user1.authHeaders))
const messages = yield* _(
client.Messages.retrieveMessages({
payload: yield* _(
user1.addChallengeForMainInbox({markAsPulled: false})
),
headers: Schema.decodeSync(CommonHeaders)({
'user-agent': 'VexlNSE/1 CFNetwork/1494.0.7 Darwin/23.4.0',
}),
})
)
expect(messages.messages.map((one) => one.message)).toContain(
'messageRetrievedByNse'
)

// Message must not be marked as pulled by the read-only retrieve.
const messageRows = yield* _(sql`
SELECT
pulled
FROM
message
WHERE
message = 'messageRetrievedByNse'
`)
expect(messageRows[0]?.pulled).toBe(false)

// Inbox metadata must stay untouched (NOT NULL column intact).
const clientVersionAfter = (yield* _(sql`
SELECT
client_version AS "clientVersion"
FROM
inbox
WHERE
public_key = ${inboxHash}
`))[0]?.clientVersion
expect(clientVersionAfter).toBe(clientVersionBefore)
})
)
})

it('Retruns an error when inbox does not exist', async () => {
await runPromiseInMockedEnvironment(
Effect.gen(function* (_) {
Expand Down
2 changes: 2 additions & 0 deletions apps/chat-service/src/__tests__/utils/addChallengeForKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const addChallengeForKey =
T &
RequestBaseWithChallenge & {
readonly senderPublicKey: PublicKeyPemBase64 // Make this compatible with all requests is ignored when ot used
readonly markAsPulled: boolean // Make this compatible with retrieveMessages request, ignored when not used
},
AddingChallengeError,
HttpClient.HttpClient | TestRequestHeaders
Expand All @@ -64,6 +65,7 @@ export const addChallengeForKey =

yield* _(TestRequestHeaders.setHeaders(initHeaders))
return {
markAsPulled: true,
...request,
publicKey: key.publicKeyPemBase64,
publicKeyV2: Option.none(),
Expand Down
44 changes: 27 additions & 17 deletions apps/chat-service/src/routes/messages/retrieveMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@ export const retrieveMessages = HttpApiBuilder.handler(
yield* _(validateChallengeInBody(req.payload))

const inbox = yield* _(ensureInboxExists(req.payload.publicKey))
const inboxDb = yield* _(InboxDbService)
yield* _(
inboxDb.updateInboxMetadata({
clientVersion: req.headers.clientVersionOrNone,
platform: req.headers.clientPlatformOrNone,
id: inbox.id,
})
)

// markAsPulled=false is the strictly read-only path used by the iOS
// notification service extension. It must not write anything: no
// pulled flags and no inbox metadata (the NSE sends no Vexl
// user-agent/client-version, so writing here would overwrite the
// app-reported metadata with NULL and violate the NOT NULL column).
if (req.payload.markAsPulled) {
const inboxDb = yield* _(InboxDbService)
yield* _(
inboxDb.updateInboxMetadata({
clientVersion: req.headers.clientVersionOrNone,
platform: req.headers.clientPlatformOrNone,
id: inbox.id,
})
)
}

const messagesDb = yield* _(MessagesDbService)
const messages = yield* _(messagesDb.findMessagesByInboxId(inbox.id))
Expand Down Expand Up @@ -61,15 +69,17 @@ export const retrieveMessages = HttpApiBuilder.handler(
)
)

yield* _(
messagesToReturn,
Array.map((message) =>
messagesDb.updateMessageAsPulledByMessageRecord(
message.messageRecord.id
)
),
(effects) => Effect.all(effects, {batching: true})
)
if (req.payload.markAsPulled) {
yield* _(
messagesToReturn,
Array.map((message) =>
messagesDb.updateMessageAsPulledByMessageRecord(
message.messageRecord.id
)
),
(effects) => Effect.all(effects, {batching: true})
)
}

return {
messages: Array.map(
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ dist/

# Maestro
!.eas/build/build-and-maestro-test.yml

# SwiftPM build artifacts of the local NSE core package (also keeps
# prettier out of the vendored dependency checkouts - prettier v3 respects
# this .gitignore)
/native/VexlNotificationCore/.build/
43 changes: 43 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export default {
// 'icon': extra.iconV2, // Does not work due to this: https://github.com/expo/expo/issues/39782
'supportsTablet': false,
'bundleIdentifier': extra.packageName,
// Required by @bacons/apple-targets for signing the NSE extension target.
'appleTeamId': 'KQNTW88PVA',
'config': {
'usesNonExemptEncryption': false,
},
Expand All @@ -101,6 +103,9 @@ export default {
'CFBundleAllowMixedLocalizations': true,
'NSLocationWhenInUseUsageDescription':
'Vexl needs access to you location to show your position on the map. Location will never be share with anyone (even ourselves).',
// App Group shared with the notification service extension (NSE). Read
// at runtime by the vexl-nse-bridge local module and the NSE itself.
'VexlAppGroup': `group.${extra.packageName}.shared`,
// 'NSAppTransportSecurity': {'NSAllowsArbitraryLoads': true},
},
'googleServicesFile': extra.googleServicesInfoPlistFile,
Expand All @@ -122,6 +127,12 @@ export default {
'entitlements': {
'aps-environment':
process.env.NODE_ENV === 'development' ? 'development' : 'production',
// Shared container + keychain access group for the notification
// service extension (targets/vexl-nse). On iOS an app-group id is a
// valid keychain access group as-is, so one entitlement covers both.
'com.apple.security.application-groups': [
`group.${extra.packageName}.shared`,
],
},
},
'android': {
Expand Down Expand Up @@ -336,10 +347,42 @@ export default {
'androidGoogleMapsApiKey': process.env.ANDROID_MAP_API_KEY,
},
],
// Links the local VexlNotificationCore Swift package to the VexlNSE
// target. MUST stay listed before @bacons/apple-targets: config-plugin
// mods run last-registered-first, so apple-targets (which creates the
// target) has to execute before this plugin's action can find it.
'./expo-plugins/with-nse-local-spm.js',
// Generates the iOS notification service extension target from
// targets/vexl-nse (rich chat notification previews).
'@bacons/apple-targets',
Comment thread
kaladivo marked this conversation as resolved.
],
'extra': {
'eas': {
'projectId': 'dbcc5b47-6c4a-4faf-a345-e9cd8a680c32',
// Declares the notification service extension (targets/vexl-nse, Xcode
// target VexlNSE) to EAS. This project uses remote iOS credentials and
// CNG (ios/ is gitignored, regenerated by prebuild on every build), so
// EAS must know the extension exists before prebuild to generate and
// validate its provisioning profile and App Group entitlement. Bundle
// id and app group mirror targets/vexl-nse/expo-target.config.js and
// the ios.entitlements block above.
'build': {
'experimental': {
'ios': {
'appExtensions': [
{
'targetName': 'VexlNSE',
'bundleIdentifier': `${extra.packageName}.nse`,
'entitlements': {
'com.apple.security.application-groups': [
`group.${extra.packageName}.shared`,
],
},
},
],
},
},
},
},
...extra,
},
Expand Down
Loading
Loading