An invite link could be consumed and then silently dropped, so the join sheet appeared only sometimes (TODO #148), and the legacy schemes dead-ended on a blank screen that only a force-stop cleared (TODO #144). Both are deep-link delivery, so they are fixed together. #148 - delivery crosses three owners and each lets go before the next has hold: 1. native LinkModule captures the VIEW intent into `pendingLink` 2. the RN poller reads it - and getPendingLink() NULLS it on read, so native has now forgotten it 3. the shell stores it, then clears that state and injects `if (window.__pearHandleInvite) { … }` Step 3 is the hole. `webViewReady` means the DOM loaded, not that the bundle has run, so the guard can be false - and then the injection is a silent no-op with the URL already gone from native AND from React state. Nothing retries, because nothing knows anything was lost. main.jsx already buffered a LATER version of this race (an invite arriving before <App> mounts its listener), but that only helps once the handler exists. The injected snippet is now total: deliver if it can, park the URL on `window.__pearEarlyInvites` if it cannot, and the bundle drains the park the moment it defines the handler. No polling, no retry state machine. #144 - app/join.tsx rendered an empty View, waited 2s, emitted a `pearLink` DeviceEvent and never navigated. Two things wrong: NOTHING LISTENS FOR `pearLink` - there is not one addListener for it in the app, so the route delivered nothing at all and the URL only ever arrived via the native queue - and it never navigated away, so on a cold start it was the only stack entry and Back exited the app. The route now replaces straight to index, whose poller does the actual delivery. 313 unit tests, 10 new. They do not test a string: they EVALUATE the injected snippet against a stub window in both orders, and the bundle-ready-second order is the bug. Confirmed by reverting the injection to its old form, where 6 of them fail including the regression itself. Also covers order preservation, no double delivery on a second drain, and that a URL carrying quotes, backslashes, newlines or U+2028/U+2029 cannot break out of the snippet - it arrives from a link someone else sent, so that is script injection into our own WebView. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HG8ayyquJuDMPSVLKDQKVh
126 lines
5.2 KiB
JavaScript
126 lines
5.2 KiB
JavaScript
// TODO #148 - an invite could be consumed by the shell and then silently
|
|
// dropped, so the join sheet appeared only sometimes.
|
|
//
|
|
// These do not test a string. They EVALUATE the snippet the shell injects,
|
|
// against a stub window, in both orders that matter: bundle-ready-first and
|
|
// bundle-ready-second. The second order is the bug, and before the fix it loses
|
|
// the URL entirely.
|
|
// (bugfix/deep-link-delivery)
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const vm = require('node:vm')
|
|
const { buildInviteInjection, drainEarlyInvites, EARLY_INVITE_KEY } = require('../src/lib/inviteDelivery.js')
|
|
|
|
const URL_A = 'https://peerloomllc.com/join?group=Zw%3D%3D&name=Family&key=' + 'a'.repeat(64)
|
|
const URL_B = 'https://peerloomllc.com/join?group=aA%3D%3D&name=Work&key=' + 'b'.repeat(64)
|
|
|
|
// Run an injection payload the way react-native-webview does: as source text,
|
|
// against a page's `window`.
|
|
function inject (win, url) {
|
|
const ctx = vm.createContext({ window: win })
|
|
return vm.runInContext(buildInviteInjection(url), ctx)
|
|
}
|
|
|
|
function freshWindow () {
|
|
return {}
|
|
}
|
|
|
|
test('handler already defined: delivered straight through', () => {
|
|
// The lucky ordering, and the only one that ever worked.
|
|
const seen = []
|
|
const win = freshWindow()
|
|
win.__pearHandleInvite = (u) => seen.push(u)
|
|
inject(win, URL_A)
|
|
assert.deepEqual(seen, [URL_A])
|
|
assert.equal(win[EARLY_INVITE_KEY], undefined, 'nothing should be parked when it was delivered')
|
|
})
|
|
|
|
test('THE #148 REGRESSION: handler not defined yet, and the invite survives', () => {
|
|
// The unlucky ordering. `webViewReady` means the DOM loaded, not that the
|
|
// bundle ran, and by this point the URL is gone from native (getPendingLink
|
|
// nulls on read) and from React state. Before the fix the guard was
|
|
// `if (window.__pearHandleInvite)` and this dropped it on the floor.
|
|
const win = freshWindow()
|
|
inject(win, URL_A)
|
|
// `Array.from`: the park array is created inside the vm realm, so its
|
|
// prototype differs and deepStrictEqual would reject it on that alone.
|
|
assert.deepEqual(Array.from(win[EARLY_INVITE_KEY]), [URL_A], 'the invite must be parked, not lost')
|
|
|
|
// Bundle runs, defines the handler, drains.
|
|
const seen = []
|
|
win.__pearHandleInvite = (u) => seen.push(u)
|
|
const drained = drainEarlyInvites(win, win.__pearHandleInvite)
|
|
assert.deepEqual(Array.from(drained), [URL_A])
|
|
assert.deepEqual(seen, [URL_A], 'the invite must arrive once the handler exists')
|
|
})
|
|
|
|
test('several early invites keep their order', () => {
|
|
const win = freshWindow()
|
|
inject(win, URL_A)
|
|
inject(win, URL_B)
|
|
const seen = []
|
|
win.__pearHandleInvite = (u) => seen.push(u)
|
|
drainEarlyInvites(win, win.__pearHandleInvite)
|
|
assert.deepEqual(seen, [URL_A, URL_B])
|
|
})
|
|
|
|
test('draining twice does not deliver twice', () => {
|
|
// The bundle may call this on a re-entry; a duplicate would open the join
|
|
// sheet a second time for an invite already handled.
|
|
const win = freshWindow()
|
|
inject(win, URL_A)
|
|
const seen = []
|
|
win.__pearHandleInvite = (u) => seen.push(u)
|
|
drainEarlyInvites(win, win.__pearHandleInvite)
|
|
drainEarlyInvites(win, win.__pearHandleInvite)
|
|
assert.deepEqual(seen, [URL_A])
|
|
})
|
|
|
|
test('draining an empty park is a no-op, which is the common case', () => {
|
|
// Most opens are not from a link at all.
|
|
const win = freshWindow()
|
|
win.__pearHandleInvite = () => { throw new Error('must not be called') }
|
|
assert.deepEqual(drainEarlyInvites(win, win.__pearHandleInvite), [])
|
|
})
|
|
|
|
test('drain tolerates a missing window or handler', () => {
|
|
assert.deepEqual(drainEarlyInvites(null, () => {}), [])
|
|
assert.deepEqual(drainEarlyInvites({}, undefined), [])
|
|
})
|
|
|
|
test('a URL with quotes and backslashes cannot break out of the snippet', () => {
|
|
// The URL is attacker-influenced: it arrives from a link someone else sent.
|
|
// If it could terminate the string literal it would be script injection into
|
|
// the app's own WebView.
|
|
const nasty = `https://peerloomllc.com/join?name=");alert('x');//&key=` + 'c'.repeat(64) + '\\'
|
|
const win = freshWindow()
|
|
inject(win, nasty)
|
|
assert.deepEqual(Array.from(win[EARLY_INVITE_KEY]), [nasty], 'the URL must survive verbatim')
|
|
})
|
|
|
|
test('a URL with newlines and unicode separators survives', () => {
|
|
// JSON.stringify escapes U+2028 / U+2029, which a JS parser treats as line
|
|
// terminators and which would otherwise split the statement in two.
|
|
const weird = 'https://peerloomllc.com/join?name=a\u2028b\u2029c\nd&key=' + 'd'.repeat(64)
|
|
const win = freshWindow()
|
|
inject(win, weird)
|
|
assert.deepEqual(Array.from(win[EARLY_INVITE_KEY]), [weird])
|
|
})
|
|
|
|
test('the snippet evaluates to true, as react-native-webview expects', () => {
|
|
// Every other injectJavaScript call in the shell ends `true;` for the same
|
|
// reason: a bare trailing expression warns on some Android versions.
|
|
const win = freshWindow()
|
|
win.__pearHandleInvite = () => {}
|
|
assert.equal(inject(win, URL_A), true)
|
|
})
|
|
|
|
test('the snippet is a single statement with no stray newlines', () => {
|
|
// It is injected as one line into a WebView; a raw newline in the source
|
|
// would be harmless here but a literal one INSIDE the URL would not, which is
|
|
// what the test above covers. This pins the shape.
|
|
const src = buildInviteInjection(URL_A)
|
|
assert.doesNotMatch(src, /\n/)
|
|
assert.match(src, /^\(function\(\)\{/)
|
|
})
|