test: validation manuelle auth middleware — 3/3 verts

This commit is contained in:
Hermann_Kitio 2026-04-16 13:52:08 +03:00
parent f71498668f
commit 2fba6f2003
6 changed files with 281 additions and 0 deletions

13
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"dotenv": "^17.4.2",
"hono": "^4.7.7", "hono": "^4.7.7",
"stripe": "^17.7.0" "stripe": "^17.7.0"
}, },
@ -1455,6 +1456,18 @@
"node": ">=6" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View file

@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"dotenv": "^17.4.2",
"hono": "^4.7.7", "hono": "^4.7.7",
"stripe": "^17.7.0" "stripe": "^17.7.0"
}, },

View file

@ -1,3 +1,4 @@
import 'dotenv/config'
import { Hono } from 'hono' import { Hono } from 'hono'
import { serve } from '@hono/node-server' import { serve } from '@hono/node-server'
import authRoutes from './routes/auth' import authRoutes from './routes/auth'

View 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
View 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
View 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