test: validation manuelle auth middleware — 3/3 verts
This commit is contained in:
parent
f71498668f
commit
2fba6f2003
6 changed files with 281 additions and 0 deletions
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"hono": "^4.7.7",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
|
|
@ -1455,6 +1456,18 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"hono": "^4.7.7",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dotenv/config'
|
||||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import authRoutes from './routes/auth'
|
||||
|
|
|
|||
163
src/middleware/__tests__/auth.test.ts
Normal file
163
src/middleware/__tests__/auth.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { authMiddleware } from '../auth'
|
||||
import type { AppVariables } from '../auth'
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: vi.fn(),
|
||||
},
|
||||
from: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
const mockProfile = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
plan: 'free',
|
||||
simulations_used: 2,
|
||||
stripe_customer_id: null,
|
||||
stripe_subscription_id: null,
|
||||
plan_expires_at: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
function createTestApp() {
|
||||
const app = new Hono<{ Variables: AppVariables }>()
|
||||
app.post('/auth/verify-token', authMiddleware, (c) => {
|
||||
const profile = c.get('profile')
|
||||
return c.json({ id: profile.id, email: profile.email, plan: profile.plan })
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('retourne 401 si le header Authorization est absent', async () => {
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('AUTH_REQUIRED')
|
||||
expect(body.error).toBe(true)
|
||||
})
|
||||
|
||||
it('retourne 401 si le header Authorization ne commence pas par Bearer', async () => {
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Basic abc123' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('AUTH_REQUIRED')
|
||||
})
|
||||
|
||||
it('retourne 401 si le JWT est invalide (erreur Supabase)', async () => {
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
|
||||
data: { user: null },
|
||||
error: { message: 'Invalid JWT' } as any,
|
||||
})
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer invalid-token' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('AUTH_REQUIRED')
|
||||
})
|
||||
|
||||
it('retourne 401 si Supabase retourne user null sans erreur', async () => {
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
|
||||
data: { user: null },
|
||||
error: null,
|
||||
})
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer some-token' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('AUTH_REQUIRED')
|
||||
})
|
||||
|
||||
it('retourne 401 si le profil est introuvable en base', async () => {
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
|
||||
data: { user: { id: 'user-123', email: 'test@example.com' } as any },
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn(() => ({ data: null, error: { message: 'Not found' } })),
|
||||
})),
|
||||
})),
|
||||
} as any)
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer valid-token' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.code).toBe('AUTH_REQUIRED')
|
||||
})
|
||||
|
||||
it('passe au handler suivant avec profil complet si JWT valide', async () => {
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
|
||||
data: { user: { id: 'user-123', email: 'test@example.com' } as any },
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn(() => ({ data: mockProfile, error: null })),
|
||||
})),
|
||||
})),
|
||||
} as any)
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer valid-token' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.id).toBe('user-123')
|
||||
expect(body.email).toBe('test@example.com')
|
||||
expect(body.plan).toBe('free')
|
||||
})
|
||||
|
||||
it('passe au handler suivant avec un profil premium', async () => {
|
||||
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
|
||||
data: { user: { id: 'user-456', email: 'premium@example.com' } as any },
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(supabase.from).mockReturnValueOnce({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
single: vi.fn(() => ({
|
||||
data: { ...mockProfile, id: 'user-456', email: 'premium@example.com', plan: 'premium' },
|
||||
error: null,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
} as any)
|
||||
const app = createTestApp()
|
||||
const res = await app.request('/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer premium-token' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.plan).toBe('premium')
|
||||
})
|
||||
})
|
||||
82
src/middleware/auth.ts
Normal file
82
src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { Context, Next } from 'hono'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type AuthProfile = {
|
||||
id: string
|
||||
email: string
|
||||
plan: string
|
||||
simulations_used: number
|
||||
stripe_customer_id: string | null
|
||||
stripe_subscription_id: string | null
|
||||
plan_expires_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AppVariables = {
|
||||
user: AuthUser
|
||||
profile: AuthProfile
|
||||
}
|
||||
|
||||
export async function authMiddleware(
|
||||
c: Context<{ Variables: AppVariables }>,
|
||||
next: Next
|
||||
) {
|
||||
const authHeader = c.req.header('Authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json(
|
||||
{
|
||||
error: true,
|
||||
code: 'AUTH_REQUIRED',
|
||||
message: 'Authentification requise.',
|
||||
},
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token)
|
||||
|
||||
if (authError || !user) {
|
||||
return c.json(
|
||||
{
|
||||
error: true,
|
||||
code: 'AUTH_REQUIRED',
|
||||
message: 'Token invalide ou expiré.',
|
||||
},
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (profileError || !profile) {
|
||||
return c.json(
|
||||
{
|
||||
error: true,
|
||||
code: 'AUTH_REQUIRED',
|
||||
message: 'Profil utilisateur introuvable.',
|
||||
},
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
c.set('user', { id: user.id, email: user.email ?? '' })
|
||||
c.set('profile', profile as AuthProfile)
|
||||
|
||||
await next()
|
||||
}
|
||||
21
src/routes/auth.ts
Normal file
21
src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Hono } from 'hono'
|
||||
import { authMiddleware } from '../middleware/auth'
|
||||
import type { AppVariables } from '../middleware/auth'
|
||||
|
||||
const auth = new Hono<{ Variables: AppVariables }>()
|
||||
|
||||
auth.post('/verify-token', authMiddleware, (c) => {
|
||||
const profile = c.get('profile')
|
||||
return c.json(
|
||||
{
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
plan: profile.plan,
|
||||
simulations_used: profile.simulations_used,
|
||||
plan_expires_at: profile.plan_expires_at,
|
||||
},
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
export default auth
|
||||
Loading…
Add table
Add a link
Reference in a new issue