Files
website/server/test/notificationsRoutes.test.js
wtclaude 416761f8f7
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s
feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:13:48 -05:00

41 lines
1.6 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/auth.routes')
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'] }],
]
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()
}
})