Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions bimex-indexer/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@ function json(req, res, status, data) {
res.end(body);
}

/**
* Same as json() but adds Cache-Control headers for public, read-only GET
* responses. Errors and non-200 responses never carry cache headers.
*
* Cache-Control: public, max-age=<seconds>, stale-while-revalidate=<2*seconds>
* Configured via PUBLIC_CACHE_SECONDS (default 15).
*/
const PUBLIC_CACHE_SECONDS = parseInt(process.env.PUBLIC_CACHE_SECONDS ?? '15', 10);
const PUBLIC_STALE_SECONDS = PUBLIC_CACHE_SECONDS * 2;

function jsonCacheable(req, res, status, data) {
const body = JSON.stringify(data);
setCorsHeaders(req, res);
setSecurityHeaders(res);
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${PUBLIC_CACHE_SECONDS}, stale-while-revalidate=${PUBLIC_STALE_SECONDS}`,
});
res.end(body);
}

/**
* Logs full error details server-side (with a context tag) and responds to
* the client with a safe, generic Spanish message — never exposing table
Expand Down Expand Up @@ -286,7 +307,7 @@ async function route(req, res) {
const { data, error } = await q;
return error
? errorInterno(req, res, '[db-read] GET /proyectos', error, 'Error de base de datos')
: json(req, res, 200, data);
: jsonCacheable(req, res, 200, data);
}

// GET /proyectos/:id
Expand All @@ -299,7 +320,7 @@ async function route(req, res) {
}
return errorInterno(req, res, '[db-read] GET /proyectos/:id', error, 'Error de base de datos');
}
return json(req, res, 200, data);
return jsonCacheable(req, res, 200, data);
}

// GET /proyectos/:id/aportaciones
Expand All @@ -308,7 +329,7 @@ async function route(req, res) {
.from('aportaciones').select('*').eq('proyecto_id', parts[1]).order('timestamp');
return error
? errorInterno(req, res, '[db-read] GET /proyectos/:id/aportaciones', error, 'Error de base de datos')
: json(req, res, 200, data);
: jsonCacheable(req, res, 200, data);
}

// GET /backers/:address/aportaciones
Expand All @@ -318,7 +339,7 @@ async function route(req, res) {
.eq('contribuidor', parts[1]).order('timestamp');
return error
? errorInterno(req, res, '[db-read] GET /backers/:address/aportaciones', error, 'Error de base de datos')
: json(req, res, 200, data);
: jsonCacheable(req, res, 200, data);
}

// GET /eventos[?tipo=X&limit=N&offset=M]
Expand All @@ -330,7 +351,7 @@ async function route(req, res) {
const { data, count, error } = await q;
return error
? errorInterno(req, res, '[db-read] GET /eventos', error, 'Error de base de datos')
: json(req, res, 200, { data, count });
: jsonCacheable(req, res, 200, { data, count });
}

// GET /stats
Expand All @@ -355,7 +376,7 @@ async function route(req, res) {
.reduce((s, a) => s + Number(a.monto ?? 0), 0),
numero_contribuidores: contribuidoresUnicos.size,
};
return json(req, res, 200, stats);
return jsonCacheable(req, res, 200, stats);
}

// GET /audit[?action=X&limit=N&offset=M&format=csv]
Expand Down
29 changes: 23 additions & 6 deletions bimex-indexer/tests/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ function expectSecurityHeaders(headers) {
expect(headers['referrer-policy']).toBe('no-referrer');
}

function expectCacheHeaders(headers, maxAge = 15) {
const staleRevalidate = maxAge * 2;
expect(headers['cache-control']).toBe(`public, max-age=${maxAge}, stale-while-revalidate=${staleRevalidate}`);
}

function expectNoCacheHeaders(headers) {
// 4xx/5xx responses MUST NOT carry Cache-Control
expect(headers['cache-control']).toBeUndefined();
}

// ─── Tests ─────────────────────────────────────────────────────────────────
describe('api.js REST Endpoints', () => {
beforeEach(() => {
Expand Down Expand Up @@ -218,17 +228,18 @@ describe('api.js REST Endpoints', () => {

// ── GET /proyectos ───────────────────────────────────────────────────────
describe('GET /proyectos', () => {
it('returns 200 and project list', async () => {
it('returns 200 and project list with Cache-Control', async () => {
const projects = [{ id: 1, nombre: 'A' }, { id: 2, nombre: 'B' }];
mockSupabase._data = projects;
mockSupabase._error = null;

const res = await req({ path: '/proyectos', method: 'GET' });
expect(res.status).toBe(200);
expect(res.body).toEqual(projects);
expectCacheHeaders(res.headers);
});

it('returns 500 when supabase errors', async () => {
it('returns 500 without Cache-Control when supabase errors', async () => {
mockSupabase._data = null;
mockSupabase._error = { message: 'relation "proyectos" does not exist' };

Expand All @@ -237,22 +248,25 @@ describe('api.js REST Endpoints', () => {
// Must NOT expose raw Supabase internals to the client
expect(res.body.error).toBe('Error de base de datos');
expect(res.body.error).not.toContain('relation');
// Errors must NOT carry cache headers
expectNoCacheHeaders(res.headers);
});
});

// ── GET /proyectos/:id ───────────────────────────────────────────────────
describe('GET /proyectos/:id', () => {
it('returns 200 for found project', async () => {
it('returns 200 with Cache-Control for found project', async () => {
const project = { id: 5, nombre: 'Test' };
// .single() is the last call – make it resolve with project
mockSupabase.single.mockResolvedValue({ data: project, error: null });

const res = await req({ path: '/proyectos/5', method: 'GET' });
expect(res.status).toBe(200);
expect(res.body).toEqual(project);
expectCacheHeaders(res.headers);
});

it('returns 404 on PGRST116 error with generic message', async () => {
it('returns 404 on PGRST116 error without Cache-Control', async () => {
mockSupabase.single.mockResolvedValue({
data: null,
error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' },
Expand All @@ -262,25 +276,27 @@ describe('api.js REST Endpoints', () => {
expect(res.status).toBe(404);
// Must return a safe message, not the raw PostgREST error
expect(res.body.error).toBe('Proyecto no encontrado');
expectNoCacheHeaders(res.headers);
});
});

// ── GET /eventos ─────────────────────────────────────────────────────────
describe('GET /eventos', () => {
it('returns 200 and event list', async () => {
it('returns 200 and event list with Cache-Control', async () => {
const events = [{ tx_hash: 'abc', ledger: 100 }];
mockSupabase._data = events;
mockSupabase._error = null;

const res = await req({ path: '/eventos', method: 'GET' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ data: events });
expectCacheHeaders(res.headers);
});
});

// ── GET /stats ────────────────────────────────────────────────────────────
describe('GET /stats', () => {
it('returns computed stats', async () => {
it('returns computed stats with Cache-Control', async () => {
const proyectosData = {
data: [
{ estado: 'EtapaInicial', total_aportado: '1000', yield_entregado: '50', meta: '5000' },
Expand Down Expand Up @@ -315,6 +331,7 @@ describe('api.js REST Endpoints', () => {
capital_activo: 1000,
numero_contribuidores: 0,
});
expectCacheHeaders(res.headers);
});
});

Expand Down
Loading