A notification feature is mostly things that correctly do NOT happen, and each of these is invisible until it goes wrong in production: a departed member and a revoked guest are not recipients; a mute subtracts per Team and leaves the user's other Teams alone; the author of a post never receives the notification about it; forums switched off silences the forum streams including the digest; a Team's first roster wakes nobody; a failed send does not stamp `last_digest_at`. Two real defects came out of writing them. `Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived `filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id 0, so it was harmless — which is exactly why it would never have been noticed. Fixed in all three places that filter ids. `recipientIds: db.recipientIds` in the model captured the function OBJECT at require time, so the layer below could never be substituted. That is not only untestable; it means the model was not really the seam it claimed to be. Wrapped so `db.x` resolves at call time. The registries catalog assertion is now an exact five-element list, so a shard-content stream creeping back into core's registration fails here rather than shipping. Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
7.9 KiB
JavaScript
179 lines
7.9 KiB
JavaScript
// Point the DB at a closed port before requiring anything that builds the pool —
|
||
// these tests inject fake models, so no real query should ever run.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const pushDispatch = require('../src/utils/pushDispatch')
|
||
// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
|
||
// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
// Run body with env keys set, then restore prior values.
|
||
function withEnv(vars, fn) {
|
||
const prior = {}
|
||
for (const [k, v] of Object.entries(vars)) {
|
||
prior[k] = process.env[k]
|
||
if (v === undefined) delete process.env[k]
|
||
else process.env[k] = v
|
||
}
|
||
try {
|
||
return fn()
|
||
} finally {
|
||
for (const [k, v] of Object.entries(prior)) {
|
||
if (v === undefined) delete process.env[k]
|
||
else process.env[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
// `mapShardEvent` and `fromShardEvent` left with module-uo in Phase 3: which
|
||
// shard event becomes which stream is the module's catalog, not core's
|
||
// infrastructure. What stays here is what core owns — the SSRF guard on the
|
||
// configured ntfy endpoint, and `publish()` delivering a content-free tickle to
|
||
// the right subscribers.
|
||
// ── isAllowedEndpoint (SSRF guard) ──────────────────────────────────────────
|
||
|
||
test('isAllowedEndpoint pins the configured ntfy origin and rejects everything else', () => {
|
||
withEnv({ NTFY_BASE_URL: 'https://ntfy.example.com', NTFY_ALLOWED_ORIGINS: undefined }, () => {
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://ntfy.example.com/UPabc'), true)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://evil.example.com/x'), false) // wrong origin
|
||
assert.equal(pushDispatch.isAllowedEndpoint('http://ntfy.example.com/x'), false) // not https
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://127.0.0.1/x'), false) // loopback
|
||
assert.equal(pushDispatch.isAllowedEndpoint('not a url'), false)
|
||
})
|
||
})
|
||
|
||
test('isAllowedEndpoint (no allow-set) permits any public https host but blocks private/loopback/http', () => {
|
||
withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, () => {
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://relay.somehost.net/UPabc'), true)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('http://relay.somehost.net/x'), false)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://10.0.0.5/x'), false)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://192.168.1.10/x'), false)
|
||
assert.equal(pushDispatch.isAllowedEndpoint('https://localhost/x'), false)
|
||
})
|
||
})
|
||
|
||
// ── publish ─────────────────────────────────────────────────────────────────
|
||
|
||
function captureFetch() {
|
||
const calls = []
|
||
return {
|
||
calls,
|
||
fetchImpl: async (url, opts) => {
|
||
calls.push({ url, opts })
|
||
return { ok: true, status: 200 }
|
||
},
|
||
}
|
||
}
|
||
|
||
test('publish sends a content-free tickle to public subscribers', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const streamCalls = []
|
||
const pushDevices = {
|
||
endpointsForStream: async (s) => {
|
||
streamCalls.push(s)
|
||
return [{ endpoint: 'https://relay.test/UPa', transport: 'unifiedpush' }]
|
||
},
|
||
endpointsForUserStream: async () => [],
|
||
}
|
||
await pushDispatch.publish('news.post', { ref: '5' }, { pushDevices, fetchImpl })
|
||
assert.deepEqual(streamCalls, ['news.post'])
|
||
assert.equal(calls.length, 1)
|
||
assert.equal(calls[0].url, 'https://relay.test/UPa')
|
||
assert.equal(calls[0].opts.method, 'POST')
|
||
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'news.post', ref: '5' })
|
||
})
|
||
})
|
||
|
||
test('publish (personal) targets only the owner’s subscribed devices', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const userStreamCalls = []
|
||
const pushDevices = {
|
||
endpointsForStream: async () => {
|
||
throw new Error('public path must not be used for a personal publish')
|
||
},
|
||
endpointsForUserStream: async (userId, s) => {
|
||
userStreamCalls.push([userId, s])
|
||
return [{ endpoint: 'https://relay.test/UPb' }]
|
||
},
|
||
}
|
||
await pushDispatch.publish('vendor.sale', { ref: '9', ownerUserId: 42 }, { pushDevices, fetchImpl })
|
||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||
assert.equal(calls.length, 1)
|
||
})
|
||
})
|
||
|
||
test('publish skips endpoints that fail the SSRF guard', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const pushDevices = {
|
||
endpointsForStream: async () => [
|
||
{ endpoint: 'http://relay.test/insecure' }, // not https → skipped
|
||
{ endpoint: 'https://10.0.0.9/private' }, // private → skipped
|
||
{ endpoint: 'https://relay.test/ok' }, // delivered
|
||
],
|
||
endpointsForUserStream: async () => [],
|
||
}
|
||
await pushDispatch.publish('server.status', { ref: 'down' }, { pushDevices, fetchImpl })
|
||
assert.equal(calls.length, 1)
|
||
assert.equal(calls[0].url, 'https://relay.test/ok')
|
||
})
|
||
})
|
||
|
||
// ── publishToUsers — the third fan-out shape (TEAMS.md §6.2) ───────────────
|
||
//
|
||
// `publish` answers "everyone subscribed" and "this one owner". Team
|
||
// notifications need "these N users", because the four `team.*` streams are
|
||
// global and which Team an event belongs to lives in the SET, not the stream id.
|
||
|
||
test('publishToUsers tickles the given set, and asks for exactly that set', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
const asked = []
|
||
const pushDevices = {
|
||
endpointsForUsersStream: async (ids, stream) => {
|
||
asked.push({ ids, stream })
|
||
return [{ endpoint: 'https://relay.test/a' }, { endpoint: 'https://relay.test/b' }]
|
||
},
|
||
}
|
||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'team:1:thread:7', userIds: [4, 9] }, { pushDevices, fetchImpl })
|
||
assert.deepEqual(asked, [{ ids: [4, 9], stream: 'team.forum.post' }])
|
||
assert.equal(calls.length, 2)
|
||
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'team.forum.post', ref: 'team:1:thread:7' })
|
||
})
|
||
})
|
||
|
||
test('publishToUsers with an empty set never touches the database', async () => {
|
||
const { calls, fetchImpl } = captureFetch()
|
||
let looked = false
|
||
const pushDevices = { endpointsForUsersStream: async () => { looked = true; return [] } }
|
||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [] }, { pushDevices, fetchImpl })
|
||
assert.equal(looked, false, 'an empty IN () is a syntax error, so the query must not be made at all')
|
||
assert.equal(calls.length, 0)
|
||
})
|
||
|
||
test('publishToUsers de-duplicates and drops non-numeric ids', async () => {
|
||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||
const { fetchImpl } = captureFetch()
|
||
const asked = []
|
||
const pushDevices = {
|
||
endpointsForUsersStream: async (ids) => { asked.push(ids); return [] },
|
||
}
|
||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [4, 4, null, 'nope', 9] }, { pushDevices, fetchImpl })
|
||
assert.deepEqual(asked, [[4, 9]])
|
||
})
|
||
})
|
||
|
||
test('publishToUsers never throws when the lookup fails', async () => {
|
||
const { fetchImpl } = captureFetch()
|
||
const pushDevices = { endpointsForUsersStream: async () => { throw new Error('down') } }
|
||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [1] }, { pushDevices, fetchImpl })
|
||
})
|