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