From f71498668ff78ceb3a40e6e074b872b6096eab39 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Thu, 16 Apr 2026 12:42:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20middleware=20plan=20+=20route=20GET=20/?= =?UTF-8?q?plans/status=20=E2=80=94=2059/59=20tests=20verts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 5 + src/middleware/__tests__/plan.test.ts | 133 ++++++++++++++++++++++++++ src/middleware/plan.ts | 42 ++++++++ src/routes/plans.ts | 31 ++++++ 4 files changed, 211 insertions(+) create mode 100644 src/middleware/__tests__/plan.test.ts create mode 100644 src/middleware/plan.ts create mode 100644 src/routes/plans.ts diff --git a/src/index.ts b/src/index.ts index 1391557..e7ce7d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import { Hono } from 'hono' import { serve } from '@hono/node-server' +import authRoutes from './routes/auth' +import plansRoutes from './routes/plans' const app = new Hono() @@ -7,6 +9,9 @@ app.get('/', (c) => { return c.json({ message: 'Expria API — OK' }, 200) }) +app.route('/auth', authRoutes) +app.route('/plans', plansRoutes) + const port = Number(process.env.PORT) || 3000 serve({ fetch: app.fetch, port }, () => { diff --git a/src/middleware/__tests__/plan.test.ts b/src/middleware/__tests__/plan.test.ts new file mode 100644 index 0000000..502d654 --- /dev/null +++ b/src/middleware/__tests__/plan.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest' +import { Hono } from 'hono' +import { planMiddleware } from '../plan' +import type { AppVariables } from '../auth' +import type { Feature } from '../../lib/access' + +function createTestApp(feature: Feature, profileOverrides: Partial = {}) { + const app = new Hono<{ Variables: AppVariables }>() + + // Simule authMiddleware : pose le profil dans le contexte + app.use('*', async (c, next) => { + c.set('user', { id: 'user-123', email: 'test@example.com' }) + c.set('profile', { + id: 'user-123', + email: 'test@example.com', + plan: 'free', + simulations_used: 0, + 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', + ...profileOverrides, + }) + await next() + }) + + app.get('/test', planMiddleware(feature), (c) => c.json({ ok: true }, 200)) + + return app +} + +describe('planMiddleware', () => { + + // Plan FREE — features bloquées + it('free — exam_mode → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('exam_mode', { plan: 'free' }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + expect(body.error).toBe(true) + }) + + it('free — detailed_report → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('detailed_report', { plan: 'free' }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + }) + + it('free — oral_t2_live → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('oral_t2_live', { plan: 'free' }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + }) + + it('free — dashboard → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('dashboard', { plan: 'free' }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + }) + + // Plan STANDARD — features autorisées + it('standard — detailed_report → 200', async () => { + const app = createTestApp('detailed_report', { plan: 'standard' }) + const res = await app.request('/test') + expect(res.status).toBe(200) + }) + + it('standard — tips → 200', async () => { + const app = createTestApp('tips', { plan: 'standard' }) + const res = await app.request('/test') + expect(res.status).toBe(200) + }) + + it('standard — dashboard → 200', async () => { + const app = createTestApp('dashboard', { plan: 'standard' }) + const res = await app.request('/test') + expect(res.status).toBe(200) + }) + + // Plan STANDARD — features bloquées + it('standard — exam_mode → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('exam_mode', { plan: 'standard' }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + }) + + it('standard — oral_t2_live → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('oral_t2_live', { plan: 'standard' }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + }) + + // Plan PREMIUM — toutes les features autorisées + it('premium — exam_mode → 200', async () => { + const app = createTestApp('exam_mode', { plan: 'premium' }) + const res = await app.request('/test') + expect(res.status).toBe(200) + }) + + it('premium — oral_t2_live → 200', async () => { + const app = createTestApp('oral_t2_live', { plan: 'premium' }) + const res = await app.request('/test') + expect(res.status).toBe(200) + }) + + it('premium — pattern_analysis → 200', async () => { + const app = createTestApp('pattern_analysis', { plan: 'premium' }) + const res = await app.request('/test') + expect(res.status).toBe(200) + }) + + // Plan inconnu — sécurité défensive + it('plan inconnu → 403 PLAN_INSUFFICIENT', async () => { + const app = createTestApp('exam_mode', { plan: 'super_premium' as any }) + const res = await app.request('/test') + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('PLAN_INSUFFICIENT') + }) + +}) diff --git a/src/middleware/plan.ts b/src/middleware/plan.ts new file mode 100644 index 0000000..b12c289 --- /dev/null +++ b/src/middleware/plan.ts @@ -0,0 +1,42 @@ +import type { Context, Next } from 'hono' +import { getPlanPermissions } from '../lib/access' +import type { Feature } from '../lib/access' +import type { AppVariables } from './auth' + +/** + * Vérifie que le profil de l'utilisateur (posé par authMiddleware) + * dispose de la permission requise. + * DOIT toujours être précédé de authMiddleware dans la chaîne. + */ +export function planMiddleware(feature: Feature) { + return async (c: Context<{ Variables: AppVariables }>, next: Next) => { + const profile = c.get('profile') + + let perms: ReturnType + try { + perms = getPlanPermissions(profile.plan as 'free' | 'standard' | 'premium') + } catch { + return c.json( + { + error: true, + code: 'PLAN_INSUFFICIENT', + message: 'Plan invalide ou non reconnu.', + }, + 403 + ) + } + + if (!perms[feature]) { + return c.json( + { + error: true, + code: 'PLAN_INSUFFICIENT', + message: "Cette fonctionnalité n'est pas disponible pour votre plan.", + }, + 403 + ) + } + + await next() + } +} diff --git a/src/routes/plans.ts b/src/routes/plans.ts new file mode 100644 index 0000000..0a0c271 --- /dev/null +++ b/src/routes/plans.ts @@ -0,0 +1,31 @@ +import { Hono } from 'hono' +import { authMiddleware } from '../middleware/auth' +import type { AppVariables } from '../middleware/auth' +import { getPlanPermissions, PLANS } from '../lib/access' + +const plans = new Hono<{ Variables: AppVariables }>() + +plans.get('/status', authMiddleware, (c) => { + const profile = c.get('profile') + const plan = profile.plan as 'free' | 'standard' | 'premium' + const permissions = getPlanPermissions(plan) + + const simulationsLifetime = PLANS[plan].simulations_lifetime + const simulationsRemaining = + simulationsLifetime === null + ? null + : Math.max(0, simulationsLifetime - profile.simulations_used) + + return c.json( + { + plan: profile.plan, + permissions, + simulations_used: profile.simulations_used, + simulations_remaining: simulationsRemaining, + plan_expires_at: profile.plan_expires_at, + }, + 200 + ) +}) + +export default plans