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