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:
Moheeb Zara
2026-05-19 20:44:08 -07:00
parent 897ff99726
commit 5ce6b29d4a
5 changed files with 220 additions and 11 deletions
+15 -4
View File
@@ -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[]
}