refactor: verwijder dode Kubo-takken voor pins en files/upload

- mapToKuboAPI had pin/ls|add|rm- en add?pin=true-takken die onbereikbaar zijn:
  proxyIPFS onderschept pins/pins/<cid>/files/upload eerst en serveert ze uit
  de lokale .data-opslag
- Dode code weggehaald; comment boven mapToKuboAPI en bij de interceptie
  documenteert dat pins/files bewust lokaal blijven, zodat de volgende lezer
  het niet 'fixt'
- files/<subpad> -> /api/v0/ls blijft: alleen bereikbaar voor methoden die
  proxyIPFS niet lokaal afhandelt (GET/DELETE worden wel afgehandeld)
- De 4 assertions die de dode takken vastlegden zijn vervangen door tests die
  de generieke doorval beschrijven, plus een comment dat een teruggezette
  Kubo-tak de test rood maakt
- Nieuwe proxy-ipfs.test.ts test het werkelijke runtime-gedrag: pins en
  files/* raken Kubo nooit (ook niet bij een geldige KUBO_API_URL), terwijl
  explorer/* en peers wél doorvallen
This commit is contained in:
maikrolf
2026-10-01 20:55:09 +02:00
parent fa7de62707
commit 8e1231a7af
3 changed files with 237 additions and 23 deletions
@@ -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/<cid> 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/<cid> 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/<cid> 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/<cid> 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/<cid> 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);
});
});
+17 -14
View File
@@ -66,24 +66,27 @@ describe('mapToKuboAPI', () => {
expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers'); expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers');
}); });
it('maps pins GET to /api/v0/pin/ls', () => { it('heeft GEEN tak voor pins — die worden lokaal afgehandeld', () => {
expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pin/ls'); // `proxyIPFS()` onderschept `pins`/`pins/<cid>` vóór de Kubo-mapping, dus
// `mapToKuboAPI` valt door naar de generieke /api/v0/<pad>. 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', () => { it('heeft GEEN specifieke tak voor files/upload — die wordt lokaal afgehandeld', () => {
expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pin/add'); // 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', () => { it('mapt files/<subpad> naar ls (alleen bereikbaar zonder GET/DELETE)', () => {
expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pin/rm?arg=QmTest123'); // 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 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('maps explorer/ls/<cid>', () => { it('maps explorer/ls/<cid>', () => {
+25 -9
View File
@@ -8,11 +8,20 @@ export async function proxyIPFS(path: string, method: string, req: NextRequest):
const p = path.replace(/^\/+/, '').replace(/\/+$/, ''); const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
// ── Lokale pin opslag (altijd) ── // ── Lokale pin opslag (altijd) ──
// Pins gaan NIET naar Kubo: `pins` en `pins/<cid>` 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/')) { if (p === 'pins' || p.startsWith('pins/')) {
return handleLocalPins(p, method, req); return handleLocalPins(p, method, req);
} }
// ── Lokale file opslag (altijd) ── // ── Lokale file opslag (altijd) ──
// Ook uploads lopen niet via Kubo `/api/v0/add`: het bestand gaat naar
// `.data/files/<cid>-<naam>` 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') { if (p === 'files/upload' && method === 'POST') {
return handleLocalUpload(req); return handleLocalUpload(req);
} }
@@ -205,6 +214,20 @@ async function handleLocalDeleteFile(cid: string): Promise<Response> {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
/**
* Vertaalt een Portal-API-pad naar een Kubo RPC-endpoint.
*
* `pins`, `pins/<cid>` 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 { export function mapToKuboAPI(path: string, method: string): string {
// Normalize: /api/node/info → /api/v0/id, etc. // Normalize: /api/node/info → /api/v0/id, etc.
const p = path.replace(/^\/+/, '').replace(/\/+$/, ''); const p = path.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -213,16 +236,9 @@ export function mapToKuboAPI(path: string, method: string): string {
return '/api/v0/id'; return '/api/v0/id';
case 'peers': case 'peers':
return '/api/v0/swarm/peers'; return '/api/v0/swarm/peers';
case 'pins':
return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add';
default: default:
if (p.startsWith('pins/')) { // Alleen niet-`pins`/niet-`files/upload`-paden komen hier terecht; `files/*`
const cid = p.slice(5); // voor niet-GET/DELETE-mutaties valt nog door naar Kubo.
return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`;
}
if (p === 'files/upload') {
return '/api/v0/add?pin=true';
}
if (p.startsWith('files/')) { if (p.startsWith('files/')) {
return '/api/v0/ls'; return '/api/v0/ls';
} }