Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
151 changes: 150 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,152 @@ 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,
})
)

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 sql = yield* _(SqlClient.SqlClient)
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 inboxHash = yield* _(
hashPublicKey(user1.mainKeyPair.publicKeyPemBase64)
)
const inboxRows = yield* _(sql`
SELECT
client_version
FROM
inbox
WHERE
public_key = ${inboxHash}
`)
expect(inboxRows[0]?.clientVersion).not.toBeNull()
Comment thread
kaladivo marked this conversation as resolved.
Outdated
})
)
})

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/
19 changes: 19 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,6 +347,14 @@ 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': {
Expand Down
73 changes: 73 additions & 0 deletions apps/mobile/expo-plugins/with-nse-local-spm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Links the local Swift package apps/mobile/native/VexlNotificationCore into
* the VexlNSE notification-service extension target created by
* @bacons/apple-targets. The remote swift-secp256k1 dependency is declared in
* the package's Package.swift, so Xcode resolves it transitively.
*
* IMPORTANT ordering: this plugin must be listed BEFORE '@bacons/apple-targets'
* in app.config.ts plugins. @expo/config-plugins runs the LAST registered mod
* action FIRST, so listing this one earlier makes apple-targets' action (which
* creates the VexlNSE target) execute before this action runs.
*/
const {withMod} = require('expo/config-plugins')

const TARGET_NAME = 'VexlNSE'
// Relative to the generated ios/ directory.
const PACKAGE_RELATIVE_PATH = '../native/VexlNotificationCore'
const PRODUCT_NAME = 'VexlNotificationCore'

module.exports = function withNseLocalSpm(config) {
return withMod(config, {
platform: 'ios',
// Custom base mod registered by @bacons/apple-targets
// (with-bacons-xcode.ts); modResults is a @bacons/xcode XcodeProject.
mod: 'xcodeProjectBeta2',
action(modConfig) {
const {
PBXBuildFile,
XCLocalSwiftPackageReference,
XCSwiftPackageProductDependency,
} = require('@bacons/xcode')

const project = modConfig.modResults
const target = project.rootObject.props.targets.find(
(one) => one.props.productName === TARGET_NAME
)
if (!target) {
throw new Error(
`[with-nse-local-spm] ${TARGET_NAME} target not found. ` +
`Is '@bacons/apple-targets' listed AFTER './expo-plugins/with-nse-local-spm.js' in app.config.ts plugins?`
)
}

const alreadyLinked = (
project.rootObject.props.packageReferences ?? []
).some((ref) => ref.props.relativePath === PACKAGE_RELATIVE_PATH)
if (alreadyLinked) return modConfig

const packageReference = XCLocalSwiftPackageReference.create(project, {
relativePath: PACKAGE_RELATIVE_PATH,
})
if (!project.rootObject.props.packageReferences) {
project.rootObject.props.packageReferences = []
}
project.rootObject.props.packageReferences.push(packageReference)

const productDependency = XCSwiftPackageProductDependency.create(
project,
{productName: PRODUCT_NAME}
)
if (!target.props.packageProductDependencies) {
target.props.packageProductDependencies = []
}
target.props.packageProductDependencies.push(productDependency)

const frameworksPhase = target.getFrameworksBuildPhase()
frameworksPhase.props.files.push(
PBXBuildFile.create(project, {productRef: productDependency})
)

return modConfig
},
})
}
6 changes: 6 additions & 0 deletions apps/mobile/modules/vexl-nse-bridge/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["VexlNseBridgeModule"]
}
}
Loading
Loading