diff --git a/src/app/api/[...path]/__tests__/proxy-ipfs.test.ts b/src/app/api/[...path]/__tests__/proxy-ipfs.test.ts new file mode 100644 index 0000000..1cd2f99 --- /dev/null +++ b/src/app/api/[...path]/__tests__/proxy-ipfs.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +/* ── Runtime-routing van proxyIPFS ── + * + * Audit: `mapToKuboAPI()` bevatte takken voor `pins` en `files/upload` die in + * runtime nooit bereikt worden — `proxyIPFS()` onderschept die paden eerder + * en serveert ze uit de lokale `.data`-opslag. De route-tests assertten die + * dode takken en gaven zo valse zekerheid. + * + * Deze tests beschrijven het WERKELIJKE gedrag: `pins` en `files/*` worden + * lokaal afgehandeld en bereiken Kubo nooit. Kubo-pass-through wordt alleen + * getest voor paden die daadwerkelijk doorvallen (explorer/*, ipns/*, …). + */ + +vi.mock('@/lib/local-pins', () => ({ + localListPins: vi.fn(async () => [{ cid: 'QmTest', name: 'a.txt', size: 3, created: 'indirect' }]), + localAddPin: vi.fn(async () => ({ cid: 'QmTest' })), + localRemovePin: vi.fn(async () => {}), +})); + +vi.mock('@/lib/local-files', () => ({ + localSaveFile: vi.fn(async () => ({ cid: 'QmSaved', size: 5 })), + localGetFile: vi.fn(async () => ({ buffer: Buffer.from('hallo'), name: 'a.txt' })), + localListFiles: vi.fn(async () => [{ cid: 'QmSaved', name: 'a.txt', size: 5, modified: 'x', path: 'a.txt' }]), + localDeleteFile: vi.fn(async () => {}), +})); + +import { proxyIPFS } from '../proxy-ipfs'; +import { localListPins, localAddPin, localRemovePin } from '@/lib/local-pins'; +import { localSaveFile, localGetFile, localListFiles, localDeleteFile } from '@/lib/local-files'; + +/** Kubo-stand-in: als er een Kubo-call komt, ziet de test dat aan de URL. */ +const kuboFetch = vi.fn(async (_url: string, _init: RequestInit) => + new Response(JSON.stringify({ Id: 'kubo' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), +); + +function req(path: string, method = 'GET', body?: BodyInit | null): NextRequest { + return new NextRequest(`http://localhost/api/${path.replace(/^\/+/, '')}`, { + method, + ...(body !== undefined && body !== null ? { body } : {}), + }); +} + +function call(path: string, method = 'GET', body?: BodyInit | null) { + return proxyIPFS('/' + path.replace(/^\/+/, ''), method, req(path, method, body)); +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.KUBO_API_URL = 'http://kubo.test:5001'; + vi.stubGlobal('fetch', kuboFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.KUBO_API_URL; +}); + +/* ═════════════════════ pins — altijd lokaal ═════════════════════ */ + +describe('pins worden lokaal behandeld, niet naar Kubo gestuurd', () => { + it('GET /api/pins leest de lokale pin-opslag', async () => { + const res = await call('pins'); + + expect(res.status).toBe(200); + expect(localListPins).toHaveBeenCalledTimes(1); + expect(await res.json()).toEqual({ Keys: { QmTest: { Type: 'indirect' } } }); + }); + + it('GET /api/pins bereikt Kubo niet', async () => { + await call('pins'); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('POST /api/pins schrijft naar de lokale pin-opslag', async () => { + const res = await call( + 'pins', + 'POST', + JSON.stringify({ cid: 'QmNieuw', name: 'b.txt', size: 7 }), + ); + + expect(res.status).toBe(200); + expect(localAddPin).toHaveBeenCalledWith('QmNieuw', 'b.txt', 7); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('POST /api/pins zonder cid weigert met 400', async () => { + const res = await call('pins', 'POST', JSON.stringify({ name: 'b.txt' })); + + expect(res.status).toBe(400); + expect(localAddPin).not.toHaveBeenCalled(); + }); + + it('DELETE /api/pins/ verwijdert uit de lokale pin-opslag', async () => { + const res = await call('pins/QmTest', 'DELETE'); + + expect(res.status).toBe(200); + expect(localRemovePin).toHaveBeenCalledWith('QmTest'); + expect(kuboFetch).not.toHaveBeenCalled(); + }); +}); + +/* ═════════════════════ files — altijd lokaal ═════════════════════ */ + +describe('files worden lokaal behandeld, niet naar Kubo gestuurd', () => { + it('POST /api/files/upload slaat op in .data/files en pint lokaal', async () => { + const form = new FormData(); + form.set('file', new File(['hallo'], 'a.txt', { type: 'text/plain' })); + + const res = await call('files/upload', 'POST', form); + + expect(res.status).toBe(200); + expect(localSaveFile).toHaveBeenCalledTimes(1); + expect(vi.mocked(localSaveFile).mock.calls[0][1]).toBeInstanceOf(Buffer); + expect(localAddPin).toHaveBeenCalledWith('QmSaved', 'a.txt', 5); + // Kern van de fix: geen Kubo /api/v0/add. + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('POST /api/files/upload zonder bestand weigert met 400', async () => { + const res = await call('files/upload', 'POST', new FormData()); + + expect(res.status).toBe(400); + expect(localSaveFile).not.toHaveBeenCalled(); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('GET /api/files leest de lokale file-opslag', async () => { + const res = await call('files'); + + expect(res.status).toBe(200); + expect(localListFiles).toHaveBeenCalledTimes(1); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('GET /api/files/ leest lokaal, ook voor onbekende CID', async () => { + const res = await call('files/QmTest'); + + expect(localGetFile).toHaveBeenCalledWith('QmTest'); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('DELETE /api/files/ verwijdert lokaal', async () => { + const res = await call('files/QmTest', 'DELETE'); + + expect(res.status).toBe(200); + expect(localDeleteFile).toHaveBeenCalledWith('QmTest'); + expect(kuboFetch).not.toHaveBeenCalled(); + }); +}); + +/* ═════════════════════ overige paden gaan wél naar Kubo ═════════════════════ */ + +describe('overige paden vallen door naar Kubo', () => { + it('GET /api/explorer/ls/ gaat naar /api/v0/ls', async () => { + await call('explorer/ls/QmTest'); + + expect(kuboFetch).toHaveBeenCalledTimes(1); + expect(kuboFetch.mock.calls[0][0]).toBe('http://kubo.test:5001/api/v0/ls?arg=QmTest'); + }); + + it('POST /api/explorer/cat/ gaat naar /api/v0/cat', async () => { + await call('explorer/cat/QmTest', 'POST'); + + expect(kuboFetch.mock.calls[0][0]).toBe('http://kubo.test:5001/api/v0/cat?arg=QmTest'); + }); + + it('GET /api/peers gaat naar /api/v0/swarm/peers', async () => { + await call('peers'); + + expect(kuboFetch.mock.calls[0][0]).toBe('http://kubo.test:5001/api/v0/swarm/peers'); + }); + + it('geeft 501 zonder KUBO_API_URL', async () => { + delete process.env.KUBO_API_URL; + + const res = await call('peers'); + + expect(res.status).toBe(501); + expect(kuboFetch).not.toHaveBeenCalled(); + }); + + it('lokale opslag blijft werken zonder KUBO_API_URL', async () => { + delete process.env.KUBO_API_URL; + + const res = await call('pins'); + + expect(res.status).toBe(200); + expect(localListPins).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/[...path]/__tests__/route.test.ts b/src/app/api/[...path]/__tests__/route.test.ts index ad21a77..d7a7d4f 100644 --- a/src/app/api/[...path]/__tests__/route.test.ts +++ b/src/app/api/[...path]/__tests__/route.test.ts @@ -66,24 +66,27 @@ describe('mapToKuboAPI', () => { expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers'); }); - it('maps pins GET to /api/v0/pin/ls', () => { - expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pin/ls'); + it('heeft GEEN tak voor pins — die worden lokaal afgehandeld', () => { + // `proxyIPFS()` onderschept `pins`/`pins/` vóór de Kubo-mapping, dus + // `mapToKuboAPI` valt door naar de generieke /api/v0/. Zou er ooit weer + // een `case 'pins'` bij komen, dan faalt deze test. + expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pins'); + expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pins'); + expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pins/QmTest123'); }); - it('maps pins POST to /api/v0/pin/add', () => { - expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pin/add'); + it('heeft GEEN specifieke tak voor files/upload — die wordt lokaal afgehandeld', () => { + // De `files/upload`-tak naar Kubo /api/v0/add is weg; het pad valt door op de + // generieke `files/`-tak. Onbereikbaar in runtime (proxyIPFS onderscheept + // POST /api/files/upload), dus de concrete waarde doet er niet toe — zolang + // er geen specifieke `files/upload`-tak terugkomt. + expect(mapToKuboAPI('files/upload', 'POST')).toBe('/api/v0/ls'); }); - it('maps pins/rm to rm with CID', () => { - expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pin/rm?arg=QmTest123'); - }); - - it('maps files/upload to add with pin', () => { - expect(mapToKuboAPI('files/upload', 'POST')).toBe('/api/v0/add?pin=true'); - }); - - it('maps files/list to ls', () => { - expect(mapToKuboAPI('files/list', 'GET')).toBe('/api/v0/ls'); + it('mapt files/ naar ls (alleen bereikbaar zonder GET/DELETE)', () => { + // GET en DELETE op `files/*` worden lokaal afgehandeld; deze tak is dus + // alleen relevant voor overige methoden. + expect(mapToKuboAPI('files/QmTest123', 'POST')).toBe('/api/v0/ls'); }); it('maps explorer/ls/', () => { diff --git a/src/app/api/[...path]/proxy-ipfs.ts b/src/app/api/[...path]/proxy-ipfs.ts index d0debf3..18696fc 100644 --- a/src/app/api/[...path]/proxy-ipfs.ts +++ b/src/app/api/[...path]/proxy-ipfs.ts @@ -8,11 +8,20 @@ export async function proxyIPFS(path: string, method: string, req: NextRequest): const p = path.replace(/^\/+/, '').replace(/\/+$/, ''); // ── Lokale pin opslag (altijd) ── + // Pins gaan NIET naar Kubo: `pins` en `pins/` worden uit `.data/pins.json` + // gelezen en daarin geschreven. Deze interceptie gebeurt VÓÓR de Kubo-mapping, + // dus `mapToKuboAPI()` hoeft (en mag) geen `pins`-tak te hebben. Zie de + // doc-comment boven `mapToKuboAPI` — of dit bewust zo blijft, hangt samen met + // de lopende vraag of pinning via `?pin=true` moet. if (p === 'pins' || p.startsWith('pins/')) { return handleLocalPins(p, method, req); } // ── Lokale file opslag (altijd) ── + // Ook uploads lopen niet via Kubo `/api/v0/add`: het bestand gaat naar + // `.data/files/-` met metadata in `.data/files.json`, en wordt + // daarna lokaal gepind. Idem voor lijst/get/delete. Vóór de Kubo-mapping, + // dus `mapToKuboAPI()` hoeft geen `files/upload`-tak te hebben. if (p === 'files/upload' && method === 'POST') { return handleLocalUpload(req); } @@ -205,6 +214,20 @@ async function handleLocalDeleteFile(cid: string): Promise { return NextResponse.json({ ok: true }); } +/** + * Vertaalt een Portal-API-pad naar een Kubo RPC-endpoint. + * + * `pins`, `pins/` en `files/upload` hebben hier GEEN tak, en dat is + * bewust: `proxyIPFS()` onderschept die paden hierboven al en serveert ze uit + * de lokale `.data`-opslag (`.data/pins.json`, `.data/files/`). Kubo-takken + * voor die paden zouden dode code zijn die suggereert dat pins/uploads naar + * Kubo gaan — voeg ze dus niet terug zonder eerst `proxyIPFS()` te wijzigen + * én `isMutatingRequest()` in `write-auth.ts` opnieuw te controleren. + * + * `method` blijft onderdeel van de signature (schrijvers in `route.ts`, + * `write-auth.ts` en de tests geven hem door) maar wordt niet meer voor de + * vertaling gebruikt: geen enkele Kubo-mapping is nog methode-afhankelijk. + */ export function mapToKuboAPI(path: string, method: string): string { // Normalize: /api/node/info → /api/v0/id, etc. const p = path.replace(/^\/+/, '').replace(/\/+$/, ''); @@ -213,16 +236,9 @@ export function mapToKuboAPI(path: string, method: string): string { return '/api/v0/id'; case 'peers': return '/api/v0/swarm/peers'; - case 'pins': - return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add'; default: - if (p.startsWith('pins/')) { - const cid = p.slice(5); - return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`; - } - if (p === 'files/upload') { - return '/api/v0/add?pin=true'; - } + // Alleen niet-`pins`/niet-`files/upload`-paden komen hier terecht; `files/*` + // voor niet-GET/DELETE-mutaties valt nog door naar Kubo. if (p.startsWith('files/')) { return '/api/v0/ls'; }