- 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.
142 lines
5.7 KiB
TypeScript
142 lines
5.7 KiB
TypeScript
// Dependency-free Postgres harness: drives the real DB through `psql` (no new
|
|
// npm dep). Models the app's two access paths:
|
|
// - service path (createAdminClient): superuser/no JWT -> RLS + the
|
|
// auth.uid()-gated trigger are bypassed, exactly like the service client.
|
|
// - user path (RLS client): role `authenticated` + a request.jwt.claims
|
|
// `sub`, so auth.uid() resolves and RLS/triggers apply, exactly like a
|
|
// logged-in member. This is the standard Supabase RLS test technique.
|
|
import { execFileSync } from 'node:child_process'
|
|
import { randomUUID } from 'node:crypto'
|
|
|
|
export const DB_URL =
|
|
process.env.INTEGRATION_DATABASE_URL ||
|
|
'postgresql://postgres:[email protected]:54322/postgres'
|
|
|
|
function run(args: string[], input?: string): { ok: boolean; out: string; err: string } {
|
|
try {
|
|
const out = execFileSync('psql', [DB_URL, '-v', 'ON_ERROR_STOP=1', ...args], {
|
|
input,
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
})
|
|
return { ok: true, out, err: '' }
|
|
} catch (e: unknown) {
|
|
const x = e as { stdout?: string; stderr?: string }
|
|
return { ok: false, out: x.stdout ?? '', err: (x.stderr ?? String(e)).trim() }
|
|
}
|
|
}
|
|
|
|
export const dbReady: boolean = run(['-tAc', 'select 1']).ok
|
|
|
|
export const uuid = randomUUID
|
|
|
|
// Run statements as the SERVICE path (no JWT -> trigger/RLS bypass).
|
|
export function execService(sql: string): { ok: boolean; err: string } {
|
|
const r = run(['-c', sql])
|
|
return { ok: r.ok, err: r.err }
|
|
}
|
|
|
|
// Run statements as a logged-in member (auth.uid() = uid, RLS active).
|
|
export function execAsUser(uid: string, sql: string): { ok: boolean; err: string } {
|
|
const claims = JSON.stringify({ sub: uid }).replace(/'/g, "''")
|
|
const wrapped =
|
|
`set local role authenticated;` +
|
|
`set local "request.jwt.claims" = '${claims}';` +
|
|
sql
|
|
const r = run(['-c', `begin; ${wrapped}; commit;`])
|
|
return { ok: r.ok, err: r.err }
|
|
}
|
|
|
|
// Query helper -> array of row objects (service path). Matches the JSON shape
|
|
// supabase-js .rpc()/.select() returns, so assertions here also validate the
|
|
// shape the server actions destructure.
|
|
export function rows<T = Record<string, unknown>>(selectSql: string): T[] {
|
|
const r = run(['-tAc', `select coalesce(json_agg(t),'[]'::json)::text from (${selectSql}) t`])
|
|
if (!r.ok) throw new Error(r.err)
|
|
return JSON.parse(r.out.trim() || '[]') as 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',
|
|
`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)
|
|
// 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[]
|
|
}
|
|
|
|
// Minimal seed helpers. They THROW on failure so a seeding problem surfaces
|
|
// immediately instead of cascading into confusing FK errors downstream. All
|
|
// ids are random so parallel test files never collide.
|
|
function must(sql: string): void {
|
|
const r = execService(sql)
|
|
if (!r.ok) throw new Error(`seed failed: ${r.err}\nSQL: ${sql.slice(0, 200)}`)
|
|
}
|
|
|
|
export function seedSpace(): { spaceId: string } {
|
|
const spaceId = uuid()
|
|
must(`insert into spaces (id, name, slug) values ('${spaceId}','itest','itest-${spaceId.slice(0, 8)}');`)
|
|
return { spaceId }
|
|
}
|
|
|
|
// space_members.user_id FKs auth.users(id), so an auth user must exist first
|
|
// (auth.users only requires id; we add an email for readability/cleanup).
|
|
export function seedMember(
|
|
spaceId: string,
|
|
opts: { role?: string; status?: string } = {},
|
|
): { memberId: string; userId: string } {
|
|
const memberId = uuid()
|
|
const userId = uuid()
|
|
must(`insert into auth.users (id, email) values ('${userId}','it-${userId}@itest.local');`)
|
|
must(
|
|
`insert into space_members (id, space_id, user_id, role, status, display_name)
|
|
values ('${memberId}','${spaceId}','${userId}','${opts.role ?? 'member'}','${opts.status ?? 'current'}','itest member');`,
|
|
)
|
|
return { memberId, userId }
|
|
}
|
|
|
|
export function seedClassSession(
|
|
spaceId: string,
|
|
capacity: number | null,
|
|
): { classId: string; sessionId: string } {
|
|
const classId = uuid()
|
|
const sessionId = uuid()
|
|
must(`insert into classes (id, space_id, title) values ('${classId}','${spaceId}','itest class');`)
|
|
must(
|
|
`insert into class_sessions (id, class_id, space_id, starts_at, capacity)
|
|
values ('${sessionId}','${classId}','${spaceId}', now() + interval '7 days', ${capacity == null ? 'null' : capacity});`,
|
|
)
|
|
return { classId, sessionId }
|
|
}
|
|
|
|
export function seedEquipment(spaceId: string): { equipmentId: string } {
|
|
const equipmentId = uuid()
|
|
must(`insert into equipment (id, space_id, name) values ('${equipmentId}','${spaceId}','itest rig');`)
|
|
return { equipmentId }
|
|
}
|
|
|
|
// Cleanup: remove the space's auth.users first (FK from space_members), then
|
|
// the space (cascades members/classes/equipment/reservations). Space-scoped
|
|
// so it is safe under parallel test files.
|
|
export function dropSpace(spaceId: string): void {
|
|
execService(
|
|
`delete from auth.users where id in (select user_id from space_members where space_id='${spaceId}' and user_id is not null);
|
|
delete from spaces where id='${spaceId}';`,
|
|
)
|
|
}
|