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>
83 lines
3.5 KiB
JavaScript
83 lines
3.5 KiB
JavaScript
// Point the DB at a closed port BEFORE requiring anything that builds the pool —
|
|
// these cases reject at requireAuth (no session token) before any query runs.
|
|
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 { startApp } = require('./_helper')
|
|
const authRouter = require('../src/router/v1/auth')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
// The push-notification self surface (/auth/me/devices*, /auth/me/notifications/*)
|
|
// must be mounted AND gated: an unauthenticated caller gets 401 on every route —
|
|
// never 404 (route missing) and never 200 (gate bypassed).
|
|
test('/auth/me push routes reject unauthenticated callers with 401', async () => {
|
|
const app = await startApp((a) => a.use('/api/v1/auth', authRouter))
|
|
try {
|
|
const calls = [
|
|
['GET', '/api/v1/auth/me/devices'],
|
|
['POST', '/api/v1/auth/me/devices', { endpoint: 'https://ntfy.example.com/UPabc' }],
|
|
['DELETE', '/api/v1/auth/me/devices/1'],
|
|
['GET', '/api/v1/auth/me/notifications/streams'],
|
|
['GET', '/api/v1/auth/me/notifications/subscriptions'],
|
|
['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }],
|
|
// Phase 6's two. Per-Team preferences are as much a private fact as the
|
|
// subscriptions beside them — the LIST of Teams a user may be notified
|
|
// about answers "which guilds is this person in".
|
|
['GET', '/api/v1/auth/me/notifications/teams'],
|
|
['PUT', '/api/v1/auth/me/notifications/teams', { teams: [] }],
|
|
]
|
|
for (const [method, path, body] of calls) {
|
|
const res = await fetch(app.url + path, {
|
|
method,
|
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
assert.equal(res.status, 401, `${method} ${path} should be 401, got ${res.status}`)
|
|
}
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
// ── The unsubscribe endpoint (TEAMS.md §6.4) ───────────────────────────────
|
|
//
|
|
// The mirror image of every test above: this one is reached WITHOUT a session, on
|
|
// purpose, because the person following it is reading their mail. So the things
|
|
// worth asserting are that it is mounted unauthenticated, that it says the same
|
|
// thing whatever the token was, and that the GET twin does not write.
|
|
|
|
const publicRouter = require('../src/router/v1/public')
|
|
|
|
test('unsubscribe answers 200 unauthenticated, and says nothing about the token', async () => {
|
|
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
|
|
try {
|
|
// A forged token and a well-formed one must be indistinguishable from the
|
|
// outside — otherwise this is an oracle for which (user, Team) pairs exist.
|
|
for (const token of ['1.1.1.AAAAAAAAAAAAAAAAAAAAAA', 'total-nonsense']) {
|
|
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/${token}`, { method: 'POST' })
|
|
assert.equal(res.status, 200)
|
|
assert.deepEqual(await res.json(), { ok: true })
|
|
}
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('a GET on the same path redirects and does not act', async () => {
|
|
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
|
|
try {
|
|
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/1.1.1.AAAAAAAAAAAAAAAAAAAAAA`, {
|
|
redirect: 'manual',
|
|
})
|
|
assert.equal(res.status, 302)
|
|
assert.match(res.headers.get('location') || '', /\/unsubscribe\//)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|