test: integration coverage for dues_payment_methods RLS and dispatcher skip
- dues_payment_methods RLS: member read, admin-only write, cross-space denial, 046 unverified-admin status gate (4 cases). - notification dispatcher: a muted category becomes 'skipped' while billing always sends; no pref row sends (default on). First real exercise of the skip path (cron is gated off in prod). - Fix integration/db.ts rowsAsUser: the SELECT must be the last psql -c statement or its output is swallowed (it was never used before). - Dues card: hide "No active dues subscription" for spaces without Stripe, where it read as confusing.
This commit is contained in:
@@ -53,9 +53,9 @@ export function DuesCard({ billing, methods = [] }: { billing: Billing; methods?
|
||||
? ` · renews ${new Date(billing.currentPeriodEnd).toLocaleDateString()}`
|
||||
: ''}
|
||||
</p>
|
||||
) : (
|
||||
) : stripeReady ? (
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">No active dues subscription.</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{billing?.status && (
|
||||
<Badge variant={paid ? 'default' : 'outline'}>{paid ? 'Current' : 'Action needed'}</Badge>
|
||||
|
||||
+9
-5
@@ -30,11 +30,15 @@ Branch `main`. Same session as pass-57; addressed three follow-up requests the u
|
||||
### Audit (one pass)
|
||||
- Made `isSafeDuesUrl` load-bearing (the Zod url field now refines on it, removing the duplicate inline https check). Verified ChipInput limits match the profile Zod schema (skills/interests 40×60, willing_to 20×60, affiliations 50×200). Confirmed `getMyBilling`'s only real caller is `/me` (shape change from `null` to always-object is safe). External links render `target=_blank rel=noopener noreferrer`; url validated absolute-https so no javascript:/data: scheme.
|
||||
|
||||
### NOT done / open
|
||||
- **NOT deployed, NOT committed.** Migrations 048 (notification_preferences, pass-57) + 049 (dues_payment_methods) both apply on deploy. No new env var; DEPLOYMENT unchanged.
|
||||
- **Browser click-through not done.** Verified type-check + build + 593 unit tests only; `/me` and `/settings` are auth-gated and need a live Supabase session + a space (and Stripe/links data) to render. The /me visual pass is explicitly staged for the user to review in a browser. Flagging per the working agreement (do not claim UI success without rendering it).
|
||||
- **No integration test** for dues_payment_methods RLS or the dispatcher skip (pass-57). Pure logic for both is unit-covered. Recommended follow-up (harness exists).
|
||||
- **Deploy bundling decision pending:** pass-57 (notification prefs) + pass-58 can ship together (one deploy, migrations 048+049) or separately. Awaiting user go.
|
||||
### Post-deploy audit follow-up (LOCAL, NOT yet deployed)
|
||||
Second ultrathink audit after the `ab8c22a` deploy found NO deploy-blocking bug. Re-read the dispatcher, dues actions, and dues card with fresh eyes: URL is Zod-validated absolute-https; the `delete` platform flows through a parameterized `.eq` filter (double-gated by the space pin + RLS); writes/reads are scoped to the authenticated member; billing is never muted (logic + test). Then hardened + polished (all local, post-`ab8c22a`):
|
||||
- **Integration coverage added (now 37, was 31).** `integration/dues-payment-methods.test.ts` (4: admin insert + member read + non-admin write denied + cross-space read denied + cross-space write denied + 046 unverified-admin gate) and `integration/notification-prefs-dispatch.test.ts` (2: muted category -> `skipped` while billing always sends; no-pref-row -> sends). The dispatcher skip was unexercised in prod (cron gated off), so this is its first real exercise. All 37 pass vs local Postgres.
|
||||
- **Harness bug fixed.** `integration/db.ts` `rowsAsUser` ran `begin; set; select; commit;` in one `psql -c`, which prints only the LAST statement's result, so the SELECT output was swallowed (returned `[]`). It was never used before; now the SELECT is the last statement (session `set`, no trailing `commit`).
|
||||
- **Dues-card copy polish.** Suppressed the "No active dues subscription" line for spaces with no Stripe (it read as confusing alongside the contact-admin note for external-only / unconfigured spaces).
|
||||
|
||||
### Still open
|
||||
- **Browser click-through not done.** Verified type-check + build + 593 unit + 37 integration only; `/me` and `/settings` are auth-gated and need a live Supabase session to render. The /me visual pass is staged for the owner to review in a browser. Flagging per the working agreement (do not claim UI success without rendering it).
|
||||
- The audit follow-up above (integration tests + harness fix + dues-card copy) is committed locally but NOT yet deployed; the integration tests are non-runtime (CI/dev only). The dues-card copy is the only prod-affecting line and rides the next deploy unless deployed sooner.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-4
@@ -58,14 +58,25 @@ export function rows<T = Record<string, unknown>>(selectSql: string): T[] {
|
||||
|
||||
export function rowsAsUser<T = Record<string, unknown>>(uid: string, selectSql: string): T[] {
|
||||
const claims = JSON.stringify({ sub: uid }).replace(/'/g, "''")
|
||||
// The SELECT must be the LAST statement: psql -c with several statements
|
||||
// only prints the final command's result, so a trailing `commit` would
|
||||
// swallow the SELECT output. Use session-level `set` (the one-shot psql
|
||||
// connection exits afterward) and end on the SELECT.
|
||||
const r = run([
|
||||
'-tAc',
|
||||
`begin; set local role authenticated; set local "request.jwt.claims" = '${claims}';` +
|
||||
`select coalesce(json_agg(t),'[]'::json)::text from (${selectSql}) t; commit;`,
|
||||
`set role authenticated; set "request.jwt.claims" = '${claims}'; ` +
|
||||
`select coalesce(json_agg(t),'[]'::json)::text from (${selectSql}) t;`,
|
||||
])
|
||||
if (!r.ok) throw new Error(r.err)
|
||||
// last non-empty line is the json (BEGIN/SET/COMMIT print nothing with -tA)
|
||||
const line = r.out.trim().split('\n').filter(Boolean).pop() ?? '[]'
|
||||
// json_agg(coalesce(...,'[]')) yields a line starting with '['; pick it and
|
||||
// ignore any psql command tags (SET) that may print around it.
|
||||
const line =
|
||||
r.out
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.startsWith('['))
|
||||
.pop() ?? '[]'
|
||||
return JSON.parse(line) as T[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// RLS for dues_payment_methods (migration 049): admin-configured external dues
|
||||
// links are member-readable but admin/board-writable, and scoped per space.
|
||||
// Proven against real Postgres via the role-simulation harness (set role
|
||||
// authenticated + request.jwt.claims sub), the same technique as
|
||||
// privilege-status-gate.test.ts.
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import {
|
||||
dbReady,
|
||||
execService,
|
||||
execAsUser,
|
||||
rowsAsUser,
|
||||
seedSpace,
|
||||
seedMember,
|
||||
dropSpace,
|
||||
} from './db'
|
||||
|
||||
const d = describe.skipIf(!dbReady)
|
||||
|
||||
d('dues_payment_methods RLS', () => {
|
||||
const spaces: string[] = []
|
||||
function space(): string {
|
||||
const { spaceId } = seedSpace()
|
||||
spaces.push(spaceId)
|
||||
return spaceId
|
||||
}
|
||||
afterAll(() => spaces.forEach(dropSpace))
|
||||
|
||||
it('admin can insert, any member can read, a non-admin member cannot write', () => {
|
||||
const sid = space()
|
||||
const admin = seedMember(sid, { role: 'admin' })
|
||||
const member = seedMember(sid, { role: 'member' })
|
||||
|
||||
const ins = execAsUser(
|
||||
admin.userId,
|
||||
`insert into dues_payment_methods (space_id, platform, url) values ('${sid}','paypal','https://paypal.me/itest');`,
|
||||
)
|
||||
expect(ins.ok).toBe(true)
|
||||
|
||||
const seen = rowsAsUser<{ platform: string; url: string }>(
|
||||
member.userId,
|
||||
`select platform, url from dues_payment_methods where space_id='${sid}'`,
|
||||
)
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].platform).toBe('paypal')
|
||||
|
||||
const memberWrite = execAsUser(
|
||||
member.userId,
|
||||
`insert into dues_payment_methods (space_id, platform, url) values ('${sid}','venmo','https://venmo.com/itest');`,
|
||||
)
|
||||
expect(memberWrite.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('a member of another space cannot read these methods', () => {
|
||||
const sidA = space()
|
||||
seedMember(sidA, { role: 'admin' })
|
||||
execService(
|
||||
`insert into dues_payment_methods (space_id, platform, url) values ('${sidA}','zeffy','https://www.zeffy.com/itest');`,
|
||||
)
|
||||
|
||||
const sidB = space()
|
||||
const memberB = seedMember(sidB, { role: 'member' })
|
||||
const seen = rowsAsUser(
|
||||
memberB.userId,
|
||||
`select platform from dues_payment_methods where space_id='${sidA}'`,
|
||||
)
|
||||
expect(seen).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('an admin cannot write a method into another space (WITH CHECK pins space)', () => {
|
||||
const sidA = space()
|
||||
const adminA = seedMember(sidA, { role: 'admin' })
|
||||
const sidB = space()
|
||||
|
||||
const r = execAsUser(
|
||||
adminA.userId,
|
||||
`insert into dues_payment_methods (space_id, platform, url) values ('${sidB}','paypal','https://paypal.me/cross');`,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('an unverified member cannot write even with admin role (046 status gate)', () => {
|
||||
const sid = space()
|
||||
const pending = seedMember(sid, { role: 'admin', status: 'unverified' })
|
||||
const r = execAsUser(
|
||||
pending.userId,
|
||||
`insert into dues_payment_methods (space_id, platform, url) values ('${sid}','paypal','https://paypal.me/x');`,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
// End-to-end integration of the notification dispatcher's preference check
|
||||
// (migration 048): the ROUTE HANDLER reads notification_preferences and marks
|
||||
// a muted row 'skipped' instead of sending, while billing types always send.
|
||||
// This path is gated off in prod until CRON_SECRET is provisioned, so the
|
||||
// integration harness is its first real exercise. Mirrors stripe-webhook's
|
||||
// route-handler invocation (load supabase env, lazy-import POST). With no
|
||||
// RESEND_API_KEY, sendEmail is a non-retryable no-op so an UNmuted row lands
|
||||
// on 'failed' (NOT 'skipped'), which is exactly the distinction we assert.
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { dbReady, execService, rows, seedSpace, seedMember, dropSpace } from './db'
|
||||
|
||||
const CRON_SECRET = 'itest_cron_secret'
|
||||
|
||||
function loadSupabaseEnv(): boolean {
|
||||
try {
|
||||
const out = execFileSync('supabase', ['status', '-o', 'env'], { encoding: 'utf8' })
|
||||
const get = (k: string) =>
|
||||
out.split('\n').find(l => l.startsWith(k + '='))?.slice(k.length + 1).replace(/^"|"$/g, '')
|
||||
const url = get('API_URL')
|
||||
const key = get('SERVICE_ROLE_KEY')
|
||||
if (!url || !key) return false
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = url
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = key
|
||||
process.env.CRON_SECRET = CRON_SECRET
|
||||
delete process.env.RESEND_API_KEY
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const envOk = loadSupabaseEnv()
|
||||
const d = describe.skipIf(!dbReady || !envOk)
|
||||
|
||||
async function runCron(): Promise<void> {
|
||||
const { POST } = await import('@/app/api/cron/notifications/route')
|
||||
const req = new NextRequest('http://localhost/api/cron/notifications', {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${CRON_SECRET}` },
|
||||
})
|
||||
await POST(req)
|
||||
}
|
||||
|
||||
function seedNotif(spaceId: string, memberId: string, type: string, dedupe: string): void {
|
||||
execService(
|
||||
`insert into notifications (space_id, member_id, type, channel, recipient, subject, body_html, body_text, status, dedupe_key)
|
||||
values ('${spaceId}','${memberId}','${type}','email','[email protected]','s','h','t','pending','${dedupe}');`,
|
||||
)
|
||||
}
|
||||
|
||||
function statusOf(spaceId: string, dedupe: string): string | undefined {
|
||||
const r = rows<{ status: string }>(
|
||||
`select status from notifications where space_id='${spaceId}' and dedupe_key='${dedupe}'`,
|
||||
)
|
||||
return r[0]?.status
|
||||
}
|
||||
|
||||
// Drain until the given row leaves 'pending' (the global cron may need more
|
||||
// than one pass if other test files left pending rows in the batch window).
|
||||
async function drainUntilResolved(spaceId: string, dedupe: string): Promise<void> {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (statusOf(spaceId, dedupe) !== 'pending') return
|
||||
await runCron()
|
||||
}
|
||||
}
|
||||
|
||||
d('dispatcher honors notification preferences', () => {
|
||||
const spaces: string[] = []
|
||||
afterAll(() => spaces.forEach(dropSpace))
|
||||
|
||||
it('skips a muted muteable category but always sends billing', async () => {
|
||||
const { spaceId } = seedSpace()
|
||||
spaces.push(spaceId)
|
||||
const { memberId } = seedMember(spaceId, { role: 'member' })
|
||||
execService(
|
||||
`insert into notification_preferences (space_id, member_id, category, enabled)
|
||||
values ('${spaceId}','${memberId}','bookings', false);`,
|
||||
)
|
||||
const bk = `bk-${spaceId}`
|
||||
const du = `du-${spaceId}`
|
||||
seedNotif(spaceId, memberId, 'booking_confirmed', bk)
|
||||
seedNotif(spaceId, memberId, 'dues_lapsed', du)
|
||||
|
||||
await drainUntilResolved(spaceId, bk)
|
||||
await drainUntilResolved(spaceId, du)
|
||||
|
||||
expect(statusOf(spaceId, bk)).toBe('skipped')
|
||||
// billing is never muteable: it must NOT be skipped (no RESEND -> 'failed')
|
||||
expect(statusOf(spaceId, du)).not.toBe('skipped')
|
||||
})
|
||||
|
||||
it('sends a muteable category when the member has no pref row (default on)', async () => {
|
||||
const { spaceId } = seedSpace()
|
||||
spaces.push(spaceId)
|
||||
const { memberId } = seedMember(spaceId, { role: 'member' })
|
||||
const bk = `bk2-${spaceId}`
|
||||
seedNotif(spaceId, memberId, 'booking_confirmed', bk)
|
||||
|
||||
await drainUntilResolved(spaceId, bk)
|
||||
|
||||
expect(statusOf(spaceId, bk)).not.toBe('skipped')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user