Files
oysqn.app/tests/integration/booking-constraints.test.ts
Patrick Toal 108c042921 fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)
fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required
fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false
fix(tests): correct weekSlot() keys from start/end to start_time/end_time
fix(tests): spread overlap test slots across separate ISO weeks
fix(tests): update e2e assertion to match actual authenticated home text
fix(app): hide IonMenu before user is authenticated
feat(dx): add test:all script running unit, integration, and e2e in sequence
docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
2026-04-20 14:32:37 -04:00

462 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Integration tests for booking constraints enforced by the create-reservation Edge Function
* and the database (overlap exclusion constraint, cert check trigger).
*
* Requires local Supabase running with Edge Functions served:
* npx supabase start
* npx supabase functions serve
*
* Run with:
* yarn test:integration
*
* Each describe block creates isolated test users and cleans up after itself.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
const SUPABASE_URL = process.env.SUPABASE_URL ?? 'http://localhost:54321'
const SUPABASE_ANON_KEY = process.env.SUPABASE_KEY ?? ''
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? ''
const FUNCTIONS_URL = `${SUPABASE_URL}/functions/v1`
// ─── Helpers ──────────────────────────────────────────────────────────────────
let adminClient: SupabaseClient
function makeAnonClient() {
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
})
}
async function createTestUser(email: string, certifications: string[] = [], role = 'member') {
const { data, error } = await adminClient.auth.admin.createUser({
email,
email_confirm: true,
})
if (error || !data.user) throw new Error(`Failed to create user: ${error?.message}`)
const userId = data.user.id
await adminClient.from('members').upsert({
user_id: userId,
email,
certifications,
role,
first_name: 'Test',
last_name: 'User',
}, { onConflict: 'user_id' })
return userId
}
async function getSessionToken(email: string): Promise<string> {
const anon = makeAnonClient()
const { data: linkData } = await adminClient.auth.admin.generateLink({
type: 'magiclink',
email,
})
const { data: sessionData } = await anon.auth.verifyOtp({
token_hash: linkData.properties!.hashed_token!,
type: 'magiclink',
})
return sessionData.session!.access_token
}
async function callCreateReservation(
token: string,
body: {
boat_id: string
start_time: string
end_time: string
reason?: string
},
) {
const res = await fetch(`${FUNCTIONS_URL}/create-reservation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'apikey': SUPABASE_ANON_KEY,
},
body: JSON.stringify({ reason: 'Open Sail', ...body }),
})
const json = await res.json()
return { status: res.status, body: json }
}
async function createTestBoat(requiredCerts: string[] = [], available = true): Promise<string> {
const { data, error } = await adminClient.from('boats').insert({
name: `Test Boat ${Date.now()}`,
required_certs: requiredCerts,
booking_available: available,
max_passengers: 6,
}).select('id').single()
if (error) throw new Error(`Failed to create boat: ${error.message}`)
return data.id
}
async function directInsertReservation(boatId: string, userId: string, start: string, end: string) {
const { error } = await adminClient.from('reservations').insert({
boat_id: boatId,
user_id: userId,
start_time: start,
end_time: end,
reason: 'Test',
status: 'confirmed',
})
return error
}
// Future Monday 09:0012:30 (within current ISO week, far enough ahead)
function futureSlot(daysAhead: number, hour = 9, endHour = 12, endMin = 30): { start_time: string; end_time: string } {
const d = new Date()
d.setUTCDate(d.getUTCDate() + daysAhead)
d.setUTCHours(hour, 0, 0, 0)
const e = new Date(d)
e.setUTCHours(endHour, endMin, 0, 0)
return { start_time: d.toISOString(), end_time: e.toISOString() }
}
// ─── Test Suite ───────────────────────────────────────────────────────────────
beforeAll(() => {
if (!SUPABASE_SERVICE_ROLE_KEY) {
throw new Error('SUPABASE_SERVICE_ROLE_KEY is required. Run: npx supabase status')
}
adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
})
})
// ── Overlap constraint (DB-level) ─────────────────────────────────────────────
describe('overlap constraint', () => {
const email = `test-overlap-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
beforeAll(async () => {
userId = await createTestUser(email)
boatId = await createTestBoat()
token = await getSessionToken(email)
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userId)
})
it('allows first booking for a slot', async () => {
const slot = futureSlot(14)
const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot })
expect(status).toBe(201)
})
it('rejects an overlapping booking for the same boat', async () => {
// Use a different week from the first test so the weekly pre-booking limit isn't reached
const slot = futureSlot(21)
// Book once directly via admin
await directInsertReservation(boatId, userId, slot.start_time, slot.end_time)
// Second booking for same slot via Edge Function — DB exclusion constraint fires
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot })
expect(status).toBe(409)
expect(body.error.code).toBe('slot_taken')
})
it('allows a booking on the same boat at a different time', async () => {
// Third distinct week — no pre-booking limit conflict, different time slot
const slot = futureSlot(28, 13, 16, 30)
const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot })
expect(status).toBe(201)
})
})
// ── Certification check (DB trigger + Edge Function) ─────────────────────────
describe('certification check', () => {
const emailNoCert = `test-nocert-${Date.now()}@oysqn.test`
const emailCert = `test-cert-${Date.now()}@oysqn.test`
let userNoCert: string
let userCert: string
let boatId: string
let tokenNoCert: string
let tokenCert: string
beforeAll(async () => {
boatId = await createTestBoat(['j27'])
userNoCert = await createTestUser(emailNoCert, [])
userCert = await createTestUser(emailCert, ['j27'])
tokenNoCert = await getSessionToken(emailNoCert)
tokenCert = await getSessionToken(emailCert)
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userNoCert)
await adminClient.auth.admin.deleteUser(userCert)
})
it('rejects booking when member lacks required cert', async () => {
const slot = futureSlot(35)
const { status, body } = await callCreateReservation(tokenNoCert, { boat_id: boatId, ...slot })
expect(status).toBe(422)
expect(body.error.code).toBe('cert_required')
})
it('allows booking when member has required cert', async () => {
const slot = futureSlot(36)
const { status } = await callCreateReservation(tokenCert, { boat_id: boatId, ...slot })
expect(status).toBe(201)
})
it('allows booking on a boat with no cert requirements', async () => {
const openBoat = await createTestBoat([])
const slot = futureSlot(37)
const { status } = await callCreateReservation(tokenNoCert, { boat_id: openBoat, ...slot })
expect(status).toBe(201)
await adminClient.from('boats').delete().eq('id', openBoat)
})
})
// ── Out-of-service check ──────────────────────────────────────────────────────
describe('boat out-of-service check', () => {
const email = `test-oos-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
beforeAll(async () => {
boatId = await createTestBoat([], false) // booking_available = false
userId = await createTestUser(email)
token = await getSessionToken(email)
})
afterAll(async () => {
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userId)
})
it('rejects booking when boat is out of service', async () => {
const slot = futureSlot(40)
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...slot })
expect(status).toBe(422)
expect(body.error.code).toBe('boat_unavailable')
})
})
// ── Weekly session limit ──────────────────────────────────────────────────────
describe('weekly session limit', () => {
const email = `test-weekly-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
// Use a fixed far-future week so existing data doesn't interfere
// Monday 2026-06-08 (week with no holidays)
const WEEK_BASE = new Date('2026-06-08T09:00:00Z')
function weekSlot(dayOffset: number, hour = 9): { start_time: string; end_time: string } {
const s = new Date(WEEK_BASE)
s.setUTCDate(WEEK_BASE.getUTCDate() + dayOffset)
s.setUTCHours(hour, 0, 0, 0)
const e = new Date(s)
e.setUTCHours(hour + 3, 30, 0, 0)
return { start_time: s.toISOString(), end_time: e.toISOString() }
}
beforeAll(async () => {
userId = await createTestUser(email)
boatId = await createTestBoat()
token = await getSessionToken(email)
// Set max_sessions_per_week to 2 (should already be the default)
await adminClient.from('booking_config')
.upsert({ key: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' })
// Set advance hours to 1 so our far-future bookings count as pre-bookings
await adminClient.from('booking_config')
.upsert({ key: 'open_session_advance_hours', value: 1 }, { onConflict: 'key' })
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userId)
// Restore default
await adminClient.from('booking_config')
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
})
it('allows first pre-booking of the week', async () => {
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(0) })
expect(status).toBe(201)
})
it('allows second pre-booking of the week', async () => {
const { status } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(1) })
expect(status).toBe(201)
})
it('rejects third pre-booking of the week', async () => {
const { status, body } = await callCreateReservation(token, { boat_id: boatId, ...weekSlot(2) })
expect(status).toBe(422)
expect(body.error.code).toBe('booking_limit_weekly')
})
})
// ── Weekend session limit ─────────────────────────────────────────────────────
describe('weekend session limit', () => {
const email = `test-weekend-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
// 2026-06-13 (Saturday) and 2026-06-14 (Sunday) — same 2-week period
// 2026-06-20 (Saturday) — same 2-week period as June 13 (period weeks = 2)
const SAT1 = '2026-06-13T09:00:00Z'
const SAT1_END = '2026-06-13T12:30:00Z'
const SUN1 = '2026-06-14T09:00:00Z'
const SUN1_END = '2026-06-14T12:30:00Z'
beforeAll(async () => {
userId = await createTestUser(email)
boatId = await createTestBoat()
token = await getSessionToken(email)
await adminClient.from('booking_config')
.upsert({ key: 'max_weekend_sessions_per_period', value: 1 }, { onConflict: 'key' })
await adminClient.from('booking_config')
.upsert({ key: 'weekend_period_weeks', value: 2 }, { onConflict: 'key' })
await adminClient.from('booking_config')
.upsert({ key: 'open_session_advance_hours', value: 1 }, { onConflict: 'key' })
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userId)
await adminClient.from('booking_config')
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
})
it('allows first weekend booking in the period', async () => {
const { status } = await callCreateReservation(token, {
boat_id: boatId, start_time: SAT1, end_time: SAT1_END,
})
expect(status).toBe(201)
})
it('rejects second weekend booking in the same period', async () => {
const { status, body } = await callCreateReservation(token, {
boat_id: boatId, start_time: SUN1, end_time: SUN1_END,
})
expect(status).toBe(422)
expect(body.error.code).toBe('booking_limit_weekend')
})
})
// ── Open-session window bypass ────────────────────────────────────────────────
describe('open-session window bypasses pre-booking limits', () => {
const email = `test-opensession-${Date.now()}@oysqn.test`
let userId: string
let boatId: string
let token: string
beforeAll(async () => {
userId = await createTestUser(email)
boatId = await createTestBoat()
token = await getSessionToken(email)
// Set limit to 0 pre-bookings/week so only open sessions can go through
await adminClient.from('booking_config')
.upsert({ key: 'max_sessions_per_week', value: 0 }, { onConflict: 'key' })
// Open session window: 9999 hours (everything is an open session)
await adminClient.from('booking_config')
.upsert({ key: 'open_session_advance_hours', value: 9999 }, { onConflict: 'key' })
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(userId)
await adminClient.from('booking_config')
.upsert({ key: 'max_sessions_per_week', value: 2 }, { onConflict: 'key' })
await adminClient.from('booking_config')
.upsert({ key: 'open_session_advance_hours', value: 24 }, { onConflict: 'key' })
})
it('allows booking even when pre-booking limit is 0, because slot is within open-session window', async () => {
const slot = futureSlot(1)
const { status } = await callCreateReservation(token, { boat_id: boatId, ...slot })
expect(status).toBe(201)
})
})
// ── RBAC visibility: reservation_slots view ───────────────────────────────────
describe('RBAC: reservation_slots view', () => {
const emailOwner = `test-rbac-owner-${Date.now()}@oysqn.test`
const emailOther = `test-rbac-other-${Date.now()}@oysqn.test`
let ownerUserId: string
let otherUserId: string
let boatId: string
let ownerToken: string
let otherToken: string
beforeAll(async () => {
boatId = await createTestBoat()
ownerUserId = await createTestUser(emailOwner)
otherUserId = await createTestUser(emailOther)
ownerToken = await getSessionToken(emailOwner)
otherToken = await getSessionToken(emailOther)
// Create a reservation for ownerUser directly via admin
const slot = futureSlot(50)
await directInsertReservation(boatId, ownerUserId, slot.start_time, slot.end_time)
})
afterAll(async () => {
await adminClient.from('reservations').delete().eq('boat_id', boatId)
await adminClient.from('boats').delete().eq('id', boatId)
await adminClient.auth.admin.deleteUser(ownerUserId)
await adminClient.auth.admin.deleteUser(otherUserId)
})
it('owner can see full reservation details', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${ownerToken}` } },
})
const { data, error } = await client.from('reservations').select('*').eq('boat_id', boatId)
expect(error).toBeNull()
expect(data).toHaveLength(1)
expect(data![0].user_id).toBeDefined()
expect(data![0].reason).toBeDefined()
})
it('other member sees the slot via reservation_slots (boat/time/status only)', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${otherToken}` } },
})
// reservation_slots view — should return the row
const { data: slots, error: slotsErr } = await client
.from('reservation_slots' as never)
.select('id, boat_id, start_time, end_time, status')
.eq('boat_id', boatId)
expect(slotsErr).toBeNull()
expect(slots).toHaveLength(1)
})
it('other member cannot see personal details of owner reservation via direct table query', async () => {
const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
global: { headers: { Authorization: `Bearer ${otherToken}` } },
})
// Should return 0 rows — RLS "Users can read own reservations" blocks other members
const { data } = await client.from('reservations').select('*').eq('boat_id', boatId)
const ownerRows = (data ?? []).filter((r: { user_id: string }) => r.user_id === ownerUserId)
expect(ownerRows).toHaveLength(0)
})
})