Files
website/server/test/pushDispatch.test.js
wtclaude f5e6025dcc
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 29s
test: re-point core's suite at what core still owns
25 of 82 test files left with the module. Three that core keeps needed splitting
rather than moving, and the split is the boundary in each case.

announceJobs.test.js keeps the announce PIPELINE -- the shared backoff schedule,
the parent-status rollup, core's Discord leg -- and loses the town-crier text
building and classification, which are a module's leg. pushDispatch.test.js
keeps the SSRF guard and publish() delivering a content-free tickle, and loses
mapShardEvent and the shard fan-out, which are a module's catalog.

playerRouteAccess.test.js is the one worth explaining. It guards a real past bug
-- an admin 403'd off their own characters -- and it did so through
/player/shard/accounts, which is now module-owned. The guarantee it protects is
CORE's, though: /player/* is role-agnostic self-service, staff are a superset of
players. So it stays here and asserts that through /player/appeals, a core route
with the same gate. Moving it would have left core with no test of its own tier
rule, which is precisely what regressed once before.

The remaining updates are core's own tests catching up: ctx has four more
members, registerCore now registers only what core owns (one stream, one leg, no
filled slot), and the extension-slot test asks for the DECLARED slot's router
rather than the filled one, since core declares it and a module fills it. The
gated-surface floor drops from >100 to >50 -- it is there so a filter matching
nothing fails loudly, not to track core's exact route count.

616 core tests and 160 client tests pass; the module's own suite is 351.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:08:26 -05:00

129 lines
5.5 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')
})
})