Files
website/server/test/notificationsRoutes.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

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