/** * RL-series: Kong rate-limiting on the invitation endpoint (Fase 6.3) * * Kong ships with the rate-limiting plugin; we configure `local` policy * (single-instance). This test hits the Supabase Kong gateway on localhost * and asserts that the 21st POST in the window returns 429. * * Scope note: we deliberately do NOT rate-limit /auth/v1/* at the Kong layer. * Keycloak's native `bruteForceProtected` flag on the `colectivo` realm * (maxFailureWaitSeconds=900, failureFactor=30) covers password attacks at the * IdP level — the authoritative place. A Kong limit on /auth/v1/token would * also throttle legitimate PKCE code exchanges during tests. * * Threshold (in sync with infra/kong.yml): * /rest/v1/collective_invitations POST — 20 per hour per authenticated user */ import { describe, it, expect } from 'vitest'; import { createClientAs } from '../src/supabase-clients.js'; import { ANA_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; const KONG = process.env.PUBLIC_SUPABASE_URL ?? 'http://localhost:8001'; const ANON = process.env.PUBLIC_SUPABASE_ANON_KEY!; // These tests hit Kong's real rate-limit counters (IP + token-based) and // consume quota that's shared with the rest of the integration suite. To // avoid cross-test pollution in `just test-all`, they're gated behind an // explicit env flag and run via `just test-rate-limit` (which also // restarts Kong first to ensure a clean counter state). const RUN = process.env.RUN_RATE_LIMIT_TESTS === '1'; const d = RUN ? describe : describe.skip; d('Kong rate-limit — /rest/v1/collective_invitations', () => { it('RL-02: 21st invitation POST in an hour returns 429', async () => { const ana = await createClientAs(ANA_ID); const authHeader = (ana as unknown as { rest: { headers: Record } }).rest ?.headers?.Authorization; // Fallback: grab the token manually const token = authHeader?.replace('Bearer ', '') ?? (await signBorja()); const headers = { 'Content-Type': 'application/json', apikey: ANON, Authorization: `Bearer ${token}`, Prefer: 'return=representation' }; for (let i = 0; i < 20; i++) { const r = await fetch(`${KONG}/rest/v1/collective_invitations`, { method: 'POST', headers, body: JSON.stringify({ collective_id: COLLECTIVE_ID, role: 'member', created_by: ANA_ID }) }); expect(r.status, `attempt ${i + 1} was 429`).not.toBe(429); } const limited = await fetch(`${KONG}/rest/v1/collective_invitations`, { method: 'POST', headers, body: JSON.stringify({ collective_id: COLLECTIVE_ID, role: 'member', created_by: ANA_ID }) }); expect(limited.status).toBe(429); }, 60_000); }); // Helper — sign a minimal authenticated JWT for Ana if we need to fall back. async function signBorja(): Promise { const { SignJWT } = await import('jose'); const secret = new TextEncoder().encode(process.env.SUPABASE_JWT_SECRET!); return new SignJWT({ role: 'authenticated' }) .setProtectedHeader({ alg: 'HS256' }) .setSubject(ANA_ID) .setAudience('authenticated') .setIssuedAt() .setExpirationTime('1h') .sign(secret); }