Files
hackerspace.sh/__tests__/notifications-enqueue.test.ts
T
Moheeb Zara bd8f22a3ff notifications(P4f): audit fixes, lock down 047 + best-effort enqueue paths
Two findings from an ultrathink audit of the Phase 4 work.

(1) members_with_permission was callable by any PostgREST client. Postgres
grants EXECUTE on new functions to PUBLIC by default, and this codebase
has no existing REVOKE pattern. The function returns a SET of member_ids
for any (space, permission) pair, so without a lockdown ANY authenticated
caller could enumerate "who has permission X in space Y" for spaces they
are not even members of, learning member_ids and the permission
distribution. user_has_permission has a narrower exposure (per-uid
boolean) and is needed by anon/authenticated for RLS-policy evaluation,
so it stays callable; the new function is locked to service_role since
the only legitimate caller (the form-submission fan-out) goes through
the admin client. Migration 047 + schema.sql now REVOKE EXECUTE FROM
PUBLIC and GRANT TO service_role.

(2) The Phase 4 enqueue blocks in equipment.ts (reserve, cancel) and
classes.ts (signUp, cancelMySignup waitlist promotion, updateSession
session-cancellation fan-out) used the shared helpers, but the helpers
themselves were not internally best-effort. The Stripe webhook's
original enqueueDues wrapped its entire body in try/catch so a thrown
member-lookup error could never bubble into the money path; my refactor
removed that inner wrap and relied on enqueueNotification's
best-effort. resolveMemberContact and getSpaceName could still throw on
a transient supabase network error, which would surface to the action
return value after the underlying mutation already committed: the
client would see an error even though their booking, signup, or
session-cancellation succeeded. Fixed at both layers: the helpers now
swallow throws internally (returning null / empty), and each call site
in equipment/classes is wrapped in try/catch around the lookup chain so
secondary action-specific queries (equipment name+location lookup,
promoted-signup session lookup, session-cancellation signup fan-out)
also cannot leak. forms.submitForm already had the wrap from P4d.

2 new helper tests pin the best-effort behavior; suite 567, build
clean.
2026-05-19 16:12:03 -07:00

176 lines
6.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
resolveMemberContact,
enqueueNotification,
buildManageUrl,
getSpaceName,
} from '@/lib/notifications/enqueue'
// Minimal hand-rolled mock of the supabase admin client. The helpers only use
// two query shapes: a chained select for member lookup and an upsert for the
// outbox write. Keeping it bare so the contract is obvious to a future reader.
type Row = Record<string, unknown>
function mockAdmin(opts: {
selectResult?: { data: Row | null; error?: { message: string } | null }
upsertResult?: { error: { message: string } | null }
throwOnUpsert?: boolean
throwOnMaybeSingle?: boolean
}) {
const upsert = vi.fn(async () => {
if (opts.throwOnUpsert) throw new Error('boom')
return opts.upsertResult ?? { error: null }
})
const builder = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn(async () => {
if (opts.throwOnMaybeSingle) throw new Error('network down')
return opts.selectResult ?? { data: null, error: null }
}),
upsert,
}
const from = vi.fn(() => builder)
return { from, builder, upsert } as unknown as {
from: ReturnType<typeof vi.fn>
builder: typeof builder
upsert: typeof upsert
}
}
describe('resolveMemberContact', () => {
it('returns email + display_name when the member exists in the space', async () => {
const admin = mockAdmin({
selectResult: { data: { email: 'm@x.test', display_name: 'Mia' }, error: null },
})
const r = await resolveMemberContact(admin as never, 'space-1', 'member-1')
expect(r).toEqual({ email: 'm@x.test', displayName: 'Mia' })
expect(admin.from).toHaveBeenCalledWith('space_members')
expect(admin.builder.eq).toHaveBeenCalledWith('id', 'member-1')
expect(admin.builder.eq).toHaveBeenCalledWith('space_id', 'space-1')
})
it('returns null when the (member, space) pair has no row', async () => {
const admin = mockAdmin({ selectResult: { data: null, error: null } })
const r = await resolveMemberContact(admin as never, 'space-1', 'ghost')
expect(r).toBeNull()
})
it('coerces missing fields to null', async () => {
const admin = mockAdmin({
selectResult: { data: { email: null, display_name: null }, error: null },
})
const r = await resolveMemberContact(admin as never, 'space-1', 'member-1')
expect(r).toEqual({ email: null, displayName: null })
})
it('returns null and never throws when the lookup itself throws (best-effort)', async () => {
const admin = mockAdmin({ throwOnMaybeSingle: true })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(resolveMemberContact(admin as never, 'space-1', 'member-1')).resolves.toBeNull()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
})
describe('enqueueNotification', () => {
const params = {
spaceId: 'space-1',
memberId: 'member-1',
type: 'booking_confirmed' as const,
recipient: 'm@x.test',
subject: 'Reserved',
bodyHtml: '<p>ok</p>',
bodyText: 'ok',
dedupeKey: 'booking_confirmed:r1',
}
it('upserts with onConflict + ignoreDuplicates so a replay is a no-op', async () => {
const admin = mockAdmin({ upsertResult: { error: null } })
await enqueueNotification(admin as never, params)
expect(admin.from).toHaveBeenCalledWith('notifications')
const [row, opts] = admin.upsert.mock.calls[0] as [Row, Row]
expect(row).toMatchObject({
space_id: 'space-1',
member_id: 'member-1',
type: 'booking_confirmed',
channel: 'email',
recipient: 'm@x.test',
subject: 'Reserved',
body_html: '<p>ok</p>',
body_text: 'ok',
status: 'pending',
dedupe_key: 'booking_confirmed:r1',
})
expect(opts).toEqual({ onConflict: 'space_id,dedupe_key', ignoreDuplicates: true })
})
it('skips when the recipient is empty or missing an @', async () => {
const admin = mockAdmin({})
await enqueueNotification(admin as never, { ...params, recipient: '' })
await enqueueNotification(admin as never, { ...params, recipient: 'not-an-email' })
expect(admin.from).not.toHaveBeenCalled()
})
it('never throws when the upsert errors (best-effort)', async () => {
const admin = mockAdmin({ upsertResult: { error: { message: 'rls denied' } } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(enqueueNotification(admin as never, params)).resolves.toBeUndefined()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('never throws when the upsert call itself throws (best-effort)', async () => {
const admin = mockAdmin({ throwOnUpsert: true })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(enqueueNotification(admin as never, params)).resolves.toBeUndefined()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
})
describe('buildManageUrl', () => {
const OLD = { ...process.env }
beforeEach(() => {
delete process.env.NEXT_PUBLIC_APP_URL
})
afterEach(() => {
process.env = { ...OLD }
})
it('uses the provided host + proto when given', () => {
expect(buildManageUrl('example.test', 'https')).toBe('https://example.test/me')
expect(buildManageUrl('localhost:3000', 'http')).toBe('http://localhost:3000/me')
})
it('defaults proto to https when host is given without one', () => {
expect(buildManageUrl('example.test')).toBe('https://example.test/me')
})
it('falls back to NEXT_PUBLIC_APP_URL then to the prod default', () => {
process.env.NEXT_PUBLIC_APP_URL = 'https://my.test'
expect(buildManageUrl(null)).toBe('https://my.test/me')
delete process.env.NEXT_PUBLIC_APP_URL
expect(buildManageUrl(null)).toBe('https://hackerspace.sh/me')
})
})
describe('getSpaceName', () => {
it('returns the name when present', async () => {
const admin = mockAdmin({ selectResult: { data: { name: 'Acme Space' }, error: null } })
expect(await getSpaceName(admin as never, 'space-1')).toBe('Acme Space')
})
it('returns empty string on miss', async () => {
const admin = mockAdmin({ selectResult: { data: null, error: null } })
expect(await getSpaceName(admin as never, 'ghost')).toBe('')
})
it('returns empty string when the lookup itself throws (best-effort)', async () => {
const admin = mockAdmin({ throwOnMaybeSingle: true })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(getSpaceName(admin as never, 'space-1')).resolves.toBe('')
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
})