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
Pivot strategique : DocAdenice = produit Notion-like generique. Le bridge
est livre vide a un user qui cree ses tables Baserow comme il veut. Code
sans aucune ontologie metier.
Suppressions :
- 9 entites domain metier (Personne, Formation, Bloc, Module, Attribution,
Client, Projet, Tache, Intervention) + types.ts (Role, statuts)
- baserow-repo.ts (mega-fichier 554 LOC avec 9 repos heritant BaseRepo)
- 6 routes metier (personnes, formations, projets, modules, interventions,
attributions) + tests associes
- Lookup PersonneRepo.findByEmail dans middleware auth
- Mapping DEFAULT_ROLE_SCOPES dans middleware/scopes.ts
- Cascade rollup metier dans webhooks/baserow-handler.ts
Ajouts :
- Domain generique : Table, Row, Field, View + schemas zod refondus
- 4 repos generiques : tables / rows / fields / views
- Route unique routes/tables.ts avec 9 endpoints REST CRUD generiques
- Claim JWT acadenice_permissions[] lu directement dans le middleware auth
(alimente par RBAC dynamique cote DocAdenice en R2)
- examples/acadenice-formation-hub/ : README + seed-baserow.md schema
9 tables + example-roles.md (Formateur, Developpeur, Direction, Support,
Admin avec permissions generiques)
Refactors :
- BaserowClient etendu : listTables, getTable, listFields, listViews,
getGridViewRows
- middleware/auth.ts : extractPermissions(payload), AuthenticatedUser
remplace roles[] par permissions[]
- middleware/scopes.ts : computeOidcScopes(groups, permissions, map)
- webhooks/baserow-handler.ts : invalidation generique
bridge:tables:<tableId>:* sans cascade cross-table
- lib/cache.ts : invalidateEntity -> invalidateTable(redis, tableId, rowId?)
- container.ts : drop tableIds, RepoSet={tables, rows, fields, views}
- 501 NOT_IMPLEMENTED si DB token sur endpoints /tables qui exigent JWT
Tests : 250/250 verts (depuis 319). Coverage : domain 98.9%, adapters 89%,
auth 97.08%, rate-limit 100%, cache 100%, webhooks 100%.
Quality gates verts : typecheck, lint biome, vitest, coverage thresholds.
Refs: R1 dans le pivot strategique DocAdenice Notion-like generique.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
525 lines
17 KiB
TypeScript
525 lines
17 KiB
TypeScript
/**
|
|
* Tests integration des routes /api/v1/tables/* — proxy generique R1.
|
|
* Repos faked en memoire — pas d'appel reseau.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { Field } from '../../src/domain/field.js';
|
|
import { Row } from '../../src/domain/row.js';
|
|
import { Table } from '../../src/domain/table.js';
|
|
import { View } from '../../src/domain/view.js';
|
|
import type { RepoSet } from '../../src/lib/container.js';
|
|
import { errors } from '../../src/lib/errors.js';
|
|
import {
|
|
ADMIN_TOKEN,
|
|
READ_TOKEN,
|
|
WRITE_TOKEN,
|
|
buildTestApp,
|
|
installTestContainer,
|
|
resetTestContainer,
|
|
} from '../helpers/test-app.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake repos
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class FakeTablesRepo {
|
|
public listCalls: number[] = [];
|
|
public failOnList = false;
|
|
public failOnGet = false;
|
|
|
|
constructor(
|
|
public tablesByDb: Map<number, Table[]> = new Map(),
|
|
public tablesById: Map<number, Table> = new Map(),
|
|
) {}
|
|
|
|
async list(databaseId: number): Promise<Table[]> {
|
|
this.listCalls.push(databaseId);
|
|
if (this.failOnList) {
|
|
throw errors.authInvalid();
|
|
}
|
|
return this.tablesByDb.get(databaseId) ?? [];
|
|
}
|
|
|
|
async get(tableId: number): Promise<Table> {
|
|
if (this.failOnGet) {
|
|
throw errors.authInvalid();
|
|
}
|
|
const t = this.tablesById.get(tableId);
|
|
if (!t) throw errors.notFound('Table', tableId);
|
|
return t;
|
|
}
|
|
}
|
|
|
|
class FakeFieldsRepo {
|
|
constructor(public fieldsByTable: Map<number, Field[]> = new Map()) {}
|
|
async list(tableId: number): Promise<Field[]> {
|
|
return this.fieldsByTable.get(tableId) ?? [];
|
|
}
|
|
}
|
|
|
|
class FakeViewsRepo {
|
|
constructor(
|
|
public viewsByTable: Map<number, View[]> = new Map(),
|
|
public rowsByView: Map<number, Row[]> = new Map(),
|
|
) {}
|
|
async list(tableId: number): Promise<View[]> {
|
|
return this.viewsByTable.get(tableId) ?? [];
|
|
}
|
|
async runGrid(viewId: number, _tableId: number) {
|
|
const items = this.rowsByView.get(viewId) ?? [];
|
|
return {
|
|
items,
|
|
meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 },
|
|
};
|
|
}
|
|
}
|
|
|
|
class FakeRowsRepo {
|
|
public lastCreate?: { tableId: number; fields: Record<string, unknown> };
|
|
public lastUpdate?: { tableId: number; rowId: number; fields: Record<string, unknown> };
|
|
public lastDelete?: { tableId: number; rowId: number };
|
|
public nextId = 1000;
|
|
|
|
constructor(public rowsByTable: Map<number, Row[]> = new Map()) {}
|
|
|
|
async list(tableId: number) {
|
|
const items = this.rowsByTable.get(tableId) ?? [];
|
|
return {
|
|
items,
|
|
meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 },
|
|
};
|
|
}
|
|
|
|
async get(tableId: number, rowId: number): Promise<Row> {
|
|
const items = this.rowsByTable.get(tableId) ?? [];
|
|
const found = items.find((r) => r.id === rowId);
|
|
if (!found) throw errors.notFound('Row', rowId);
|
|
return found;
|
|
}
|
|
|
|
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
|
|
this.lastCreate = { tableId, fields };
|
|
const id = this.nextId++;
|
|
return new Row({ id, tableId, fields });
|
|
}
|
|
|
|
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
|
|
this.lastUpdate = { tableId, rowId, fields };
|
|
return new Row({ id: rowId, tableId, fields });
|
|
}
|
|
|
|
async delete(tableId: number, rowId: number): Promise<void> {
|
|
this.lastDelete = { tableId, rowId };
|
|
}
|
|
}
|
|
|
|
function buildFakeRepos(opts: {
|
|
tables?: FakeTablesRepo;
|
|
fields?: FakeFieldsRepo;
|
|
views?: FakeViewsRepo;
|
|
rows?: FakeRowsRepo;
|
|
}): RepoSet {
|
|
return {
|
|
tables: (opts.tables ?? new FakeTablesRepo()) as unknown as RepoSet['tables'],
|
|
fields: (opts.fields ?? new FakeFieldsRepo()) as unknown as RepoSet['fields'],
|
|
views: (opts.views ?? new FakeViewsRepo()) as unknown as RepoSet['views'],
|
|
rows: (opts.rows ?? new FakeRowsRepo()) as unknown as RepoSet['rows'],
|
|
};
|
|
}
|
|
|
|
function bootApp(opts: Parameters<typeof buildFakeRepos>[0] = {}) {
|
|
const repos = buildFakeRepos(opts);
|
|
const container = installTestContainer({ repos });
|
|
return { app: buildTestApp(container), repos };
|
|
}
|
|
|
|
afterEach(resetTestContainer);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// /tables (metadata)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/v1/tables', () => {
|
|
it('401 sans token', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables?databaseId=5');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('400 sans databaseId', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('200 list tables d une database', async () => {
|
|
const tables = new FakeTablesRepo(
|
|
new Map([
|
|
[
|
|
5,
|
|
[
|
|
new Table({ id: 1, name: 'Personne', databaseId: 5 }),
|
|
new Table({ id: 2, name: 'Bloc', databaseId: 5 }),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
const { app } = bootApp({ tables });
|
|
const res = await app.request('/api/v1/tables?databaseId=5', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: Array<{ id: number; name: string }> };
|
|
expect(body.data).toHaveLength(2);
|
|
expect(body.data[0]?.name).toBe('Personne');
|
|
});
|
|
|
|
it('501 si le upstream Baserow renvoie 401 (DB token au lieu de JWT)', async () => {
|
|
const tables = new FakeTablesRepo();
|
|
tables.failOnList = true;
|
|
const { app } = bootApp({ tables });
|
|
const res = await app.request('/api/v1/tables?databaseId=5', {
|
|
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(501);
|
|
const body = (await res.json()) as { error: { details?: { reason?: string } } };
|
|
expect(body.error.details?.reason).toBe('jwt_required');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/tables/:tableId', () => {
|
|
it('200 avec fields embarques', async () => {
|
|
const tables = new FakeTablesRepo(
|
|
new Map(),
|
|
new Map([[42, new Table({ id: 42, name: 'X', databaseId: 5 })]]),
|
|
);
|
|
const fields = new FakeFieldsRepo(
|
|
new Map([[42, [new Field({ id: 100, name: 'nom', type: 'text', primary: true })]]]),
|
|
);
|
|
const { app } = bootApp({ tables, fields });
|
|
const res = await app.request('/api/v1/tables/42', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { id: number; name: string; fields: Array<{ name: string; primary: boolean }> };
|
|
};
|
|
expect(body.data.id).toBe(42);
|
|
expect(body.data.fields).toHaveLength(1);
|
|
expect(body.data.fields[0]?.primary).toBe(true);
|
|
});
|
|
|
|
it('400 si tableId non numerique', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/abc', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('404 si table inconnue', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/9999', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// /tables/:tableId/fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/v1/tables/:tableId/fields', () => {
|
|
it('200 list fields', async () => {
|
|
const fields = new FakeFieldsRepo(
|
|
new Map([[1, [new Field({ id: 10, name: 'titre', type: 'text', primary: true })]]]),
|
|
);
|
|
const { app } = bootApp({ fields });
|
|
const res = await app.request('/api/v1/tables/1/fields', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: Array<{ name: string }> };
|
|
expect(body.data[0]?.name).toBe('titre');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// /tables/:tableId/views
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/v1/tables/:tableId/views', () => {
|
|
it('200 list views', async () => {
|
|
const views = new FakeViewsRepo(
|
|
new Map([
|
|
[
|
|
1,
|
|
[
|
|
new View({ id: 100, name: 'Tous', type: 'grid', tableId: 1 }),
|
|
new View({ id: 101, name: 'Kanban', type: 'kanban', tableId: 1 }),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
const { app } = bootApp({ views });
|
|
const res = await app.request('/api/v1/tables/1/views', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: Array<{ id: number; type: string }> };
|
|
expect(body.data).toHaveLength(2);
|
|
expect(body.data[1]?.type).toBe('kanban');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/tables/:tableId/views/:viewId/rows', () => {
|
|
it('200 rows filtrees par la view', async () => {
|
|
const views = new FakeViewsRepo(
|
|
new Map(),
|
|
new Map([
|
|
[
|
|
100,
|
|
[
|
|
new Row({ id: 1, tableId: 1, fields: { nom: 'Alice' } }),
|
|
new Row({ id: 2, tableId: 1, fields: { nom: 'Bob' } }),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
const { app } = bootApp({ views });
|
|
const res = await app.request('/api/v1/tables/1/views/100/rows', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: Array<{ id: number; fields: Record<string, unknown> }>;
|
|
};
|
|
expect(body.data).toHaveLength(2);
|
|
expect(body.data[0]?.fields.nom).toBe('Alice');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// /tables/:tableId/rows CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/v1/tables/:tableId/rows', () => {
|
|
it('200 list paginee', async () => {
|
|
const rows = new FakeRowsRepo(
|
|
new Map([
|
|
[
|
|
5,
|
|
[
|
|
new Row({ id: 1, tableId: 5, fields: { nom: 'A' } }),
|
|
new Row({ id: 2, tableId: 5, fields: { nom: 'B' } }),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
const { app } = bootApp({ rows });
|
|
const res = await app.request('/api/v1/tables/5/rows', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: unknown[]; meta: { total: number } };
|
|
expect(body.data).toHaveLength(2);
|
|
expect(body.meta.total).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/tables/:tableId/rows/:rowId', () => {
|
|
it('200 avec fields opaques', async () => {
|
|
const rows = new FakeRowsRepo(
|
|
new Map([[5, [new Row({ id: 100, tableId: 5, fields: { nom: 'X', heures: 42 } })]]]),
|
|
);
|
|
const { app } = bootApp({ rows });
|
|
const res = await app.request('/api/v1/tables/5/rows/100', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { id: number; fields: Record<string, unknown> };
|
|
};
|
|
expect(body.data.id).toBe(100);
|
|
expect(body.data.fields.heures).toBe(42);
|
|
});
|
|
|
|
it('404 si row inconnue', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/5/rows/9999', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/tables/:tableId/rows', () => {
|
|
it('201 cree une row + invalide cache', async () => {
|
|
const rows = new FakeRowsRepo();
|
|
const { app } = bootApp({ rows });
|
|
const res = await app.request('/api/v1/tables/5/rows', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${WRITE_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ nom: 'Pierre', heures: 40 }),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as { data: { id: number; fields: Record<string, unknown> } };
|
|
expect(body.data.fields.nom).toBe('Pierre');
|
|
expect(rows.lastCreate?.tableId).toBe(5);
|
|
expect(rows.lastCreate?.fields).toEqual({ nom: 'Pierre', heures: 40 });
|
|
});
|
|
|
|
it('403 sans scope write:tables', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/5/rows', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${READ_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ nom: 'X' }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('400 si body pas un objet', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/5/rows', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${WRITE_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: '[1,2,3]',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/v1/tables/:tableId/rows/:rowId', () => {
|
|
it('200 update + payload partiel', async () => {
|
|
const rows = new FakeRowsRepo();
|
|
const { app } = bootApp({ rows });
|
|
const res = await app.request('/api/v1/tables/5/rows/100', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${WRITE_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ heures: 45 }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(rows.lastUpdate?.rowId).toBe(100);
|
|
expect(rows.lastUpdate?.fields).toEqual({ heures: 45 });
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/v1/tables/:tableId/rows/:rowId', () => {
|
|
it('204 + invalide cache', async () => {
|
|
const rows = new FakeRowsRepo();
|
|
const { app } = bootApp({ rows });
|
|
const res = await app.request('/api/v1/tables/5/rows/100', {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${WRITE_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(204);
|
|
expect(rows.lastDelete).toEqual({ tableId: 5, rowId: 100 });
|
|
});
|
|
|
|
it('403 sans scope write:tables', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/5/rows/100', {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe('Edge cases /api/v1/tables', () => {
|
|
it('400 si tableId = 0 (positif strict)', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables/0/rows', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('400 si databaseId non numerique', async () => {
|
|
const { app } = bootApp();
|
|
const res = await app.request('/api/v1/tables?databaseId=abc', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('admin token (admin:*) acces toutes les routes', async () => {
|
|
const rows = new FakeRowsRepo(
|
|
new Map([[5, [new Row({ id: 1, tableId: 5, fields: { x: 1 } })]]]),
|
|
);
|
|
const { app } = bootApp({ rows });
|
|
const res = await app.request('/api/v1/tables/5/rows', {
|
|
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('GET rows avec search query param est passe au repo', async () => {
|
|
const rows = new FakeRowsRepo();
|
|
const repo: typeof rows & { lastListSearch?: string } = rows as typeof rows & {
|
|
lastListSearch?: string;
|
|
};
|
|
const orig = rows.list.bind(rows);
|
|
rows.list = (tableId: number, opts?: { search?: string }) => {
|
|
repo.lastListSearch = opts?.search;
|
|
return orig(tableId);
|
|
};
|
|
const { app } = bootApp({ rows });
|
|
await app.request('/api/v1/tables/5/rows?search=alice', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(repo.lastListSearch).toBe('alice');
|
|
});
|
|
|
|
it('GET tables/:id renvoie les fields meme si DB token (fields use DB token)', async () => {
|
|
const tables = new FakeTablesRepo(
|
|
new Map(),
|
|
new Map([[42, new Table({ id: 42, name: 'X', databaseId: 5 })]]),
|
|
);
|
|
const fields = new FakeFieldsRepo(
|
|
new Map([
|
|
[
|
|
42,
|
|
[
|
|
new Field({ id: 1, name: 'a', type: 'text', primary: true }),
|
|
new Field({ id: 2, name: 'b', type: 'number' }),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
const { app } = bootApp({ tables, fields });
|
|
const res = await app.request('/api/v1/tables/42', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
const body = (await res.json()) as { data: { fields: Array<{ name: string }> } };
|
|
expect(body.data.fields).toHaveLength(2);
|
|
});
|
|
|
|
it('GET tables/:id 501 si DB token sur lecture metadata', async () => {
|
|
const tables = new FakeTablesRepo();
|
|
tables.failOnGet = true;
|
|
const fields = new FakeFieldsRepo();
|
|
const { app } = bootApp({ tables, fields });
|
|
const res = await app.request('/api/v1/tables/42', {
|
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(501);
|
|
});
|
|
});
|