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
This commit is contained in:
@@ -86,8 +86,7 @@ describe('magic link login — session creation', () => {
|
||||
// and /auth/callback calls supabase.auth.verifyOtp (hash-based) or
|
||||
// supabase.auth.exchangeCodeForSession (PKCE)
|
||||
const { data: sessionData, error: sessionError } = await anonClient.auth.verifyOtp({
|
||||
email: TEST_EMAIL,
|
||||
token: token!,
|
||||
token_hash: token!,
|
||||
type: 'magiclink',
|
||||
})
|
||||
|
||||
@@ -104,8 +103,7 @@ describe('magic link login — session creation', () => {
|
||||
})
|
||||
|
||||
const { data: sessionData } = await anonClient.auth.verifyOtp({
|
||||
email: TEST_EMAIL,
|
||||
token: linkData.properties!.hashed_token!,
|
||||
token_hash: linkData.properties!.hashed_token!,
|
||||
type: 'magiclink',
|
||||
})
|
||||
|
||||
|
||||
461
tests/integration/booking-constraints.test.ts
Normal file
461
tests/integration/booking-constraints.test.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* 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:00–12: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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user