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:
2026-04-20 14:32:37 -04:00
parent d07a02c9dc
commit 108c042921
33 changed files with 2745 additions and 12 deletions

View File

@@ -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',
})

View 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: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)
})
})