Complete Phase 10: authorization matrix, stale push cleanup, and retention-default tests

- Add authorization.integration.test.ts covering unauthenticated/regular-user/admin
  access across both admin-only and merely-authenticated routes
- Add stale push subscription cleanup test (disableSubscription excludes a device
  from fan-out without deleting its row/history)
- Add retention test for a topic with no override falling back to the resolved
  Admin-configured global default
- Update README status: Phases 2-10 complete
This commit is contained in:
siliconsolder committed 2026-09-07 19:02:37 +00:00
1 parent 147c0eb242
commit 651214017a
4 files changed
+215 -6

No files matched your search

+9 -5
View File
@@ -16,11 +16,15 @@ it, and it's a general-purpose tool built for a much broader audience than
something purpose-fit for this specific use case, entirely self-hosted, something purpose-fit for this specific use case, entirely self-hosted,
with no data ever leaving the LAN except the push notification itself. with no data ever leaving the LAN except the push notification itself.
Status: **Phases 2–9 complete**, Phase 10 (hardening/test-gap-filling) in Status: **Phases 2–10 complete**. Auth, topics, notification ingestion
progress. Auth, topics, notification ingestion (canonical + ntfy-compat + (canonical + ntfy-compat + webhook), rate limiting, dedup, SSE live
webhook), rate limiting, dedup, SSE live updates, retention cleanup, the updates, retention cleanup, the full user-facing UI, Web Push, and the
full user-facing UI, Web Push, and the admin UI are all built and verified admin UI are all built and verified against a live dev stack. Phase 10's
against a live dev stack. See specific test gaps (authorization matrix across roles, stale push
subscription cleanup, retention's global-default fallback) are covered by
the automated suite (`pnpm test`), and the dependency/CSP/HSTS security
pass is done — see [Backup and restore](#backup-and-restore) and the
troubleshooting entries below for the operational side. See
[docs/phase1/08-implementation-plan.md](docs/phase1/08-implementation-plan.md) [docs/phase1/08-implementation-plan.md](docs/phase1/08-implementation-plan.md)
for the phase breakdown and [MVP acceptance status](#mvp-acceptance-status) for the phase breakdown and [MVP acceptance status](#mvp-acceptance-status)
below for where things stand against the original 22 criteria. below for where things stand against the original 22 criteria.
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js'
import { getSetting } from '../../db/settings.js'
import { runMigrations } from '../../db/migrate.js'
import { users } from '../../db/schema.js'
import { buildWebApp } from '../../listeners/web.js'
import { encryptJson } from '../../lib/crypto-session.js'
// Integration test — same requirements/run command as
// modules/tokens/repository.integration.test.ts. Covers the
// admin-vs-regular-user authorization matrix across an unauthenticated,
// a regular-user, and an admin session — the gap Phase 10 (08-implementation-plan.md)
// specifically calls out.
//
// Session cookies are hand-built the same way
// notifications/image-proxy.integration.test.ts does it (AUTH_MODE=oidc
// applies inside the test container too, so requireAuth/requireAdmin need
// a real session, not the disabled-mode auto-admin).
describe('authorization matrix', () => {
let app: FastifyInstance
let adminUserId: string
let regularUserId: string
let adminCookie: string
let regularCookie: string
const adminSubject = `authz-admin-${randomUUID()}`
const regularSubject = `authz-user-${randomUUID()}`
beforeAll(async () => {
await runMigrations()
app = await buildWebApp()
await app.ready()
const [admin] = await db
.insert(users)
.values({
email: `${adminSubject}@example.test`,
displayName: 'Authz Test Admin',
authSubject: adminSubject,
role: 'admin',
})
.returning()
adminUserId = admin!.id
const [regular] = await db
.insert(users)
.values({
email: `${regularSubject}@example.test`,
displayName: 'Authz Test User',
authSubject: regularSubject,
role: 'user',
})
.returning()
regularUserId = regular!.id
const secretHex = await getSetting<string>('session_secret')
const sessionKey = Buffer.from(secretHex!, 'hex')
adminCookie = encryptJson(sessionKey, { userId: adminUserId })
regularCookie = encryptJson(sessionKey, { userId: regularUserId })
})
afterAll(async () => {
await db.delete(users).where(eq(users.id, adminUserId))
await db.delete(users).where(eq(users.id, regularUserId))
await app.close()
await pool.end()
})
it('rejects an unauthenticated request to an admin-only route', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/admin/users' })
expect(res.statusCode).toBe(401)
})
it("rejects a regular (non-admin) user's request to an admin-only route", async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { cookie: `notizarr_session=${regularCookie}` },
})
expect(res.statusCode).toBe(403)
// Confirms this 403 is specifically the role check (modules/auth/guards.ts),
// not e.g. a CSRF rejection that would also happen to be a 403.
expect(res.json().error).toBe('forbidden')
})
it('allows an admin request to an admin-only route', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { cookie: `notizarr_session=${adminCookie}` },
})
expect(res.statusCode).toBe(200)
})
it('rejects an unauthenticated request to a merely-authenticated route', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/topics' })
expect(res.statusCode).toBe(401)
})
it('allows a regular user to reach a merely-authenticated (non-admin) route', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/topics',
headers: { cookie: `notizarr_session=${regularCookie}` },
})
expect(res.statusCode).toBe(200)
})
it("prevents a regular user from removing their own admin role's protection by other means", async () => {
// Not applicable to a non-admin (they have no admin role to touch),
// but the same admin-only PATCH must still 403 for them, not just the
// GET above — a route-specific bypass would be a real gap.
const res = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${regularUserId}`,
headers: { cookie: `notizarr_session=${regularCookie}`, 'x-requested-with': 'notizarr' },
payload: { role: 'admin' },
})
expect(res.statusCode).toBe(403)
expect(res.json().error).toBe('forbidden')
})
})
@@ -4,6 +4,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js' import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js' import { runMigrations } from '../../db/migrate.js'
import { notifications, topics } from '../../db/schema.js' import { notifications, topics } from '../../db/schema.js'
import {
getStoredSystemSettings,
setStoredSystemSettings,
} from '../admin/system-settings.js'
import { runRetentionCleanup } from './retention.js' import { runRetentionCleanup } from './retention.js'
// Integration tests — need a real, migrated Postgres. Run with: // Integration tests — need a real, migrated Postgres. Run with:
@@ -12,8 +16,11 @@ import { runRetentionCleanup } from './retention.js'
describe('retention cleanup', () => { describe('retention cleanup', () => {
const shortRetentionSlug = `retention-short-${randomUUID()}` const shortRetentionSlug = `retention-short-${randomUUID()}`
const foreverSlug = `retention-forever-${randomUUID()}` const foreverSlug = `retention-forever-${randomUUID()}`
const defaultSlug = `retention-default-${randomUUID()}`
let shortTopicId: string let shortTopicId: string
let foreverTopicId: string let foreverTopicId: string
let defaultTopicId: string
let originalSystemSettings: Awaited<ReturnType<typeof getStoredSystemSettings>>
beforeAll(async () => { beforeAll(async () => {
await runMigrations() await runMigrations()
@@ -29,11 +36,25 @@ describe('retention cleanup', () => {
.values({ slug: foreverSlug, displayName: 'Forever Retention', retentionDays: 0 }) .values({ slug: foreverSlug, displayName: 'Forever Retention', retentionDays: 0 })
.returning() .returning()
foreverTopicId = foreverTopic!.id foreverTopicId = foreverTopic!.id
const [defaultTopic] = await db
.insert(topics)
.values({ slug: defaultSlug, displayName: 'Default Retention', retentionDays: null })
.returning()
defaultTopicId = defaultTopic!.id
// Pin the global default (Admin > System) to something short and known
// for the duration of this suite, rather than depending on whatever a
// real deployment happens to have it set to.
originalSystemSettings = await getStoredSystemSettings()
await setStoredSystemSettings({ ...originalSystemSettings, retentionDefaultDays: 1 })
}) })
afterAll(async () => { afterAll(async () => {
await db.delete(topics).where(eq(topics.id, shortTopicId)) await db.delete(topics).where(eq(topics.id, shortTopicId))
await db.delete(topics).where(eq(topics.id, foreverTopicId)) await db.delete(topics).where(eq(topics.id, foreverTopicId))
await db.delete(topics).where(eq(topics.id, defaultTopicId))
await setStoredSystemSettings(originalSystemSettings ?? {})
await pool.end() await pool.end()
}) })
@@ -93,4 +114,27 @@ describe('retention cleanup', () => {
.where(eq(notifications.id, veryOldNotification!.id)) .where(eq(notifications.id, veryOldNotification!.id))
expect(stillExists).toHaveLength(1) expect(stillExists).toHaveLength(1)
}) })
it('falls back to the resolved global default for a topic with no retentionDays override', async () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
const [oldNotification] = await db
.insert(notifications)
.values({
topicId: defaultTopicId,
source: 'test',
title: 'Old, no override',
message: 'Should be cleaned up via the global default (pinned to 1 day above)',
createdAt: twoDaysAgo,
})
.returning()
await runRetentionCleanup()
const stillExists = await db
.select()
.from(notifications)
.where(eq(notifications.id, oldNotification!.id))
expect(stillExists).toHaveLength(0)
})
}) })
@@ -4,7 +4,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db, pool } from '../../db/client.js' import { db, pool } from '../../db/client.js'
import { runMigrations } from '../../db/migrate.js' import { runMigrations } from '../../db/migrate.js'
import { users } from '../../db/schema.js' import { users } from '../../db/schema.js'
import { deleteUserDevice, listUserDevices, upsertPushSubscription } from './repository.js' import {
deleteUserDevice,
disableSubscription,
getActiveSubscriptionsForUser,
listUserDevices,
upsertPushSubscription,
} from './repository.js'
// Integration tests — need a real, migrated Postgres. Run with: // Integration tests — need a real, migrated Postgres. Run with:
// docker compose -f docker/docker-compose.yml exec notizarr \ // docker compose -f docker/docker-compose.yml exec notizarr \
@@ -85,4 +91,34 @@ describe('push subscription ownership', () => {
await deleteUserDevice(ownerId, first.id) await deleteUserDevice(ownerId, first.id)
}) })
it('excludes a disabled subscription from active devices without deleting the row', async () => {
// Mirrors what modules/push/fanout.ts does on a 404/410 from the push
// service: the subscription is stale (endpoint no longer valid on the
// browser's end) but the row — and its delivery-attempt history — is
// kept, just marked disabled rather than removed.
const device = await upsertPushSubscription({
userId: ownerId,
endpoint: `https://push.example.test/${randomUUID()}`,
p256dh: 'stale-key',
auth: 'stale-auth',
})
const beforeDisable = await getActiveSubscriptionsForUser(ownerId)
expect(beforeDisable.some((d) => d.id === device.id)).toBe(true)
await disableSubscription(device.id)
const afterDisable = await getActiveSubscriptionsForUser(ownerId)
expect(afterDisable.some((d) => d.id === device.id)).toBe(false)
// Still visible in the full device list (e.g. Admin > Devices) — just
// not eligible for the next push fan-out.
const allDevices = await listUserDevices(ownerId)
const stillListed = allDevices.find((d) => d.id === device.id)
expect(stillListed).toBeDefined()
expect(stillListed?.disabledAt).not.toBeNull()
await deleteUserDevice(ownerId, device.id)
})
}) })