Files
website/server/test/pushDispatch.test.js
wtclaude 5fa88baa0a test(teams): the refusals, which is most of what a notification feature is
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>
2026-08-18 14:35:23 -05:00

179 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 owners 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 })
})