Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
Wiring HTTP du bridge service. 10 endpoints livres (cf docs/19 §6.1-6.5) :
- GET /api/v1/personnes (+ /:id, + /:id/dashboard)
- GET /api/v1/formations (+ /:id avec rollups blocs/modules)
- GET /api/v1/projets (+ /:id avec rollups taches)
- POST /api/v1/modules/:id/attribuer (RG-01 -> 422, role/heures invalides -> 400)
- POST /api/v1/interventions (validation role developpeur + heures > 0)
- PATCH /api/v1/attributions/:id/heures-realisees (409 si annule/realise)
Layers ajoutees :
- src/middleware/auth.ts : Bearer brg_*, scopes JSON-encoded BRIDGE_API_TOKENS, admin:* wildcard
- src/middleware/error-handler.ts : BridgeError -> JSON shape standard
- src/lib/container.ts : DI singleton (Baserow + Redis + 9 repos), setContainer testable
- src/lib/http.ts : parseListQuery + parseBody zod helper
- src/repos/baserow-repo.ts : BaseRepo<T> abstrait + 9 sous-classes (mapping Row<->Domain)
- src/routes/{personnes,formations,projets,modules,interventions,attributions}.ts
src/index.ts reecrit : buildApp() + initContainer + auth sur /api/v1/* + ready check Baserow+Redis.
Tests : 163/163 verts (12 suites domain + 8 nouvelles : auth, repos, 6 routes).
Coverage src global : 70.77% (cible 60%). Domain 97.86%, routes 96%, middleware 86%.
Choix : BaseRepo abstrait (pas mega-generic, Ockham) ; FakeRepos in-memory pour tests routes
(pas de testcontainers ici, c'est Bloc 7) ; mapping erreurs domain -> HTTP par message texte
(fragile, sera refactor en DomainError typees au Bloc 3.2).
Hors scope (a venir) :
- Bloc 5 : rate limiting Redis
- Bloc 7 : webhook handlers Baserow + sync bidirec + cache invalidation
- Bloc 3.2 : routes /docmost/*, /sync/*, /rapports/*
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4 KiB
TypeScript
118 lines
4 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
type ApiTokenRecord,
|
|
type AuthVariables,
|
|
authMiddleware,
|
|
hasScope,
|
|
parseTokens,
|
|
requireScope,
|
|
} from '../../src/middleware/auth.js';
|
|
import { errorHandler } from '../../src/middleware/error-handler.js';
|
|
|
|
function buildApp(tokens: ApiTokenRecord[]): Hono<{ Variables: AuthVariables }> {
|
|
const map = new Map<string, ApiTokenRecord>();
|
|
for (const t of tokens) map.set(t.token, t);
|
|
const app = new Hono<{ Variables: AuthVariables }>();
|
|
app.onError(errorHandler);
|
|
app.use('/protected/*', authMiddleware(map));
|
|
app.get('/protected/read', requireScope('read:personnes'), (c) =>
|
|
c.json({ ok: true, scopes: Array.from(c.get('auth').scopes) }),
|
|
);
|
|
app.get('/protected/admin', requireScope('admin:something'), (c) => c.json({ ok: true }));
|
|
return app;
|
|
}
|
|
|
|
describe('parseTokens', () => {
|
|
it('parse JSON valide', () => {
|
|
const map = parseTokens(
|
|
JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:personnes'] }]),
|
|
);
|
|
expect(map.get('brg_x')?.name).toBe('a');
|
|
});
|
|
|
|
it('retourne map vide si raw vide', () => {
|
|
expect(parseTokens(undefined).size).toBe(0);
|
|
expect(parseTokens('').size).toBe(0);
|
|
});
|
|
|
|
it('throw si JSON invalide', () => {
|
|
expect(() => parseTokens('{nope')).toThrow(/JSON/);
|
|
});
|
|
|
|
it('throw si pas un array', () => {
|
|
expect(() => parseTokens('{"foo": 1}')).toThrow(/tableau/);
|
|
});
|
|
|
|
it('throw si entree manque token/name/scopes', () => {
|
|
expect(() => parseTokens('[{"token":"x"}]')).toThrow();
|
|
expect(() => parseTokens('[{"token":"x","name":"y"}]')).toThrow();
|
|
expect(() => parseTokens('[{"token":"x","name":"y","scopes":[1]}]')).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('hasScope', () => {
|
|
it('match exact', () => {
|
|
expect(hasScope(new Set(['read:personnes']), 'read:personnes')).toBe(true);
|
|
expect(hasScope(new Set(['read:personnes']), 'read:projets')).toBe(false);
|
|
});
|
|
it('admin:* couvre tout', () => {
|
|
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
|
|
expect(hasScope(new Set(['admin:*']), 'write:something')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('auth middleware — 5 cas', () => {
|
|
const tokens: ApiTokenRecord[] = [
|
|
{ token: 'brg_valid', name: 'demo', scopes: ['read:personnes'] },
|
|
];
|
|
|
|
it('401 si pas de header', async () => {
|
|
const app = buildApp(tokens);
|
|
const res = await app.request('/protected/read');
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_REQUIRED');
|
|
});
|
|
|
|
it('401 si format wrong (pas Bearer)', async () => {
|
|
const app = buildApp(tokens);
|
|
const res = await app.request('/protected/read', {
|
|
headers: { Authorization: 'Token brg_valid' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('401 si token inconnu', async () => {
|
|
const app = buildApp(tokens);
|
|
const res = await app.request('/protected/read', {
|
|
headers: { Authorization: 'Bearer brg_unknown' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('403 si scope manquant', async () => {
|
|
const app = buildApp(tokens);
|
|
const res = await app.request('/protected/admin', {
|
|
headers: { Authorization: 'Bearer brg_valid' },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('FORBIDDEN_SCOPE');
|
|
});
|
|
|
|
it('200 si token + scope OK', async () => {
|
|
const app = buildApp(tokens);
|
|
const res = await app.request('/protected/read', {
|
|
headers: { Authorization: 'Bearer brg_valid' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean; scopes: string[] };
|
|
expect(body.ok).toBe(true);
|
|
expect(body.scopes).toContain('read:personnes');
|
|
});
|
|
});
|