docs: Update architecture for supabase
test: Add tests for auth workflow
This commit is contained in:
126
tests/integration/auth-session.test.ts
Normal file
126
tests/integration/auth-session.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Integration tests for auth session creation via magic link flow.
|
||||
*
|
||||
* Requires local Supabase to be running:
|
||||
* DOCKER_HOST=unix:///run/user/1000/podman/podman.sock npx supabase start
|
||||
*
|
||||
* Run with:
|
||||
* yarn test:integration
|
||||
*
|
||||
* Uses Supabase admin API (service role key) to:
|
||||
* 1. Create a temporary test user
|
||||
* 2. Generate a magic link token
|
||||
* 3. Exchange the token for a session
|
||||
* 4. Verify the session is valid
|
||||
* 5. Clean up the test user
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Local Supabase defaults (from `npx supabase status`)
|
||||
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 TEST_EMAIL = `test-auth-${Date.now()}@oysqn.test`
|
||||
|
||||
let adminClient: ReturnType<typeof createClient>
|
||||
let anonClient: ReturnType<typeof createClient>
|
||||
let testUserId: string | undefined
|
||||
|
||||
beforeAll(() => {
|
||||
if (!SUPABASE_SERVICE_ROLE_KEY) {
|
||||
throw new Error(
|
||||
'SUPABASE_SERVICE_ROLE_KEY is required for integration tests.\n' +
|
||||
'Run `npx supabase status` to get the service_role key and set it as an env var.'
|
||||
)
|
||||
}
|
||||
adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
})
|
||||
anonClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (testUserId) {
|
||||
await adminClient.auth.admin.deleteUser(testUserId)
|
||||
}
|
||||
})
|
||||
|
||||
describe('magic link login — session creation', () => {
|
||||
it('creates a confirmed test user via admin API', async () => {
|
||||
const { data, error } = await adminClient.auth.admin.createUser({
|
||||
email: TEST_EMAIL,
|
||||
email_confirm: true,
|
||||
})
|
||||
expect(error).toBeNull()
|
||||
expect(data.user).toBeDefined()
|
||||
expect(data.user!.email).toBe(TEST_EMAIL)
|
||||
testUserId = data.user!.id
|
||||
})
|
||||
|
||||
it('generates a magic link token for the test user', async () => {
|
||||
const { data, error } = await adminClient.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: TEST_EMAIL,
|
||||
})
|
||||
expect(error).toBeNull()
|
||||
expect(data.properties?.hashed_token).toBeTruthy()
|
||||
})
|
||||
|
||||
it('exchanging the OTP token creates a valid session in Supabase', async () => {
|
||||
// Generate a fresh link for this test (tokens are single-use)
|
||||
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: TEST_EMAIL,
|
||||
})
|
||||
expect(linkError).toBeNull()
|
||||
|
||||
const token = linkData.properties?.hashed_token
|
||||
expect(token).toBeTruthy()
|
||||
|
||||
// Exchange the token — this is what happens when the user clicks the magic link
|
||||
// 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!,
|
||||
type: 'magiclink',
|
||||
})
|
||||
|
||||
expect(sessionError).toBeNull()
|
||||
expect(sessionData.session).not.toBeNull()
|
||||
expect(sessionData.session!.access_token).toBeTruthy()
|
||||
expect(sessionData.session!.user.email).toBe(TEST_EMAIL)
|
||||
})
|
||||
|
||||
it('session allows access to authenticated Supabase queries', async () => {
|
||||
const { data: linkData } = await adminClient.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: TEST_EMAIL,
|
||||
})
|
||||
|
||||
const { data: sessionData } = await anonClient.auth.verifyOtp({
|
||||
email: TEST_EMAIL,
|
||||
token: linkData.properties!.hashed_token!,
|
||||
type: 'magiclink',
|
||||
})
|
||||
|
||||
// Create an authenticated client using the session token
|
||||
const authedClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
global: {
|
||||
headers: { Authorization: `Bearer ${sessionData.session!.access_token}` },
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the session resolves to the correct user
|
||||
const { data: { user }, error } = await authedClient.auth.getUser()
|
||||
expect(error).toBeNull()
|
||||
expect(user).not.toBeNull()
|
||||
expect(user!.email).toBe(TEST_EMAIL)
|
||||
})
|
||||
})
|
||||
24
tests/unit/auth-middleware.test.ts
Normal file
24
tests/unit/auth-middleware.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { checkAuthRedirect } from '~/utils/auth'
|
||||
|
||||
describe('checkAuthRedirect', () => {
|
||||
it.each(['/', '/login', '/signup', '/auth/callback'])(
|
||||
'returns null for unauthenticated user on public route: %s',
|
||||
(path) => {
|
||||
expect(checkAuthRedirect(null, path)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('returns "/" for unauthenticated user on protected route', () => {
|
||||
expect(checkAuthRedirect(null, '/boats')).toBe('/')
|
||||
})
|
||||
|
||||
it('returns null for authenticated user on protected route', () => {
|
||||
expect(checkAuthRedirect({ id: 'user-123' }, '/boats')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for authenticated user on public route', () => {
|
||||
expect(checkAuthRedirect({ id: 'user-123' }, '/')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user