const path = require('path'); const { test, expect, describe, afterEach } = require('bun:test'); const { ServiceBroker } = require('moleculer'); const { isPublicRequest, createPublicSurfaceGate } = require('./public-surface'); const { basePathOf } = require('./base-path'); const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20'; describe('the core service mounts the gate in front of every route', () => { test('core.service.js wires the gate, for its own base path, as a global gateway middleware, and no SPARQL endpoint', () => { const core = require('./core/core.service.js'); const CONFIG = require('../config/config'); const base = basePathOf(CONFIG.HOME_URL); expect(core.settings.api.use).toHaveLength(1); const [gate] = core.settings.api.use; expect(gate.name).toBe('publicSurfaceGate'); let passed; gate({ method: 'GET', parsedUrl: `${base}catalog`, headers: {} }, {}, err => (passed = err === undefined)); expect(passed).toBe(true); gate({ method: 'GET', parsedUrl: `${base}key`, headers: {} }, {}, err => (passed = err === undefined)); expect(passed).toBe(false); expect(core.settings.sparqlEndpoint).toBe(false); }); test("core.service.js mounts Festipod's own routes under its base path", () => { const core = require('./core/core.service.js'); const CONFIG = require('../config/config'); const base = basePathOf(CONFIG.HOME_URL); expect(core.settings.api.routes.map(route => route.path)).toEqual([`${base}app`, `${base}as/actor/app`, `${base}catalog`]); }); }); describe('isPublicRequest — the allow-list', () => { test('every endpoint a pod or the Festipod front end calls is public', () => { for (const [method, p] of [ ['GET', '/app'], ['GET', '/as/actor/app'], ['GET', '/catalog'], ['GET', `/public-key/${UUID}`], ['HEAD', `/public-key/${UUID}`], ['GET', `/interop/access-need-group/${UUID}`], ['GET', `/interop/access-need/${UUID}`], ['GET', '/.well-known/context.jsonld'], ['GET', '/.well-known/nodeinfo'], ['GET', '/nodeinfo/2.1'], ['POST', '/as/actor/app/inbox'], ['POST', `/.webhooks/${UUID}`] ]) { expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} true`); } }); test("the app's private keys, its registrations and grants, every listing and the SPARQL endpoint are refused", () => { for (const p of [ '/key', `/key/${UUID}`, '/', // the root container: lists every container of the settings dataset '/public-key', // container listing '/interop', `/interop/application-registration/${UUID}`, `/interop/access-grant/${UUID}`, `/interop/data-grant/${UUID}`, '/interop/access-need', '/sparql', '/as/actor/app/outbox', '/as/actor/app/inbox', // GET: nobody reads the app's inbox '/as/actor/app/followers', '/as/actor', '/.well-known/webfinger', `/.webhooks/${UUID}`, // GET '/proxy' ]) { expect(`GET ${p} ${isPublicRequest('GET', p)}`).toBe(`GET ${p} false`); } }); test('every write outside the two delivery endpoints is refused', () => { for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { for (const p of ['/app', '/catalog', '/as/actor/app', `/public-key/${UUID}`, `/interop/access-need/${UUID}`, '/key', '/', '/sparql', '/proxy', '/as/actor/app/outbox']) { expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} false`); } } expect(isPublicRequest('PUT', `/.webhooks/${UUID}`)).toBe(false); expect(isPublicRequest('DELETE', '/as/actor/app/inbox')).toBe(false); }); test('a path that only resembles a public one is refused (traversal, encoding, extra segments, case)', () => { for (const p of [ `/public-key/../key/${UUID}`, '/public-key/..', `/public-key/%2e%2e/key/${UUID}`, `/public-key/${UUID}%2F..%2Fkey`, `/public-key/${UUID}/extra`, `//public-key/${UUID}`, `/Public-Key/${UUID}`, '/app/', '/apps', '/catalog/x', `/interop/access-need/${UUID}/../../key`, '/.well-known/context.jsonldx' ]) { expect(`${p} ${isPublicRequest('GET', p)}`).toBe(`${p} false`); } expect(isPublicRequest(undefined, '/app')).toBe(false); expect(isPublicRequest('GET', undefined)).toBe(false); }); }); describe('isPublicRequest — under a base path (the Coolify layout: the backend at /api/ on the front end origin)', () => { const BASE = '/api/'; test('every public endpoint is public under the base path, the /.well-known ones at the origin root', () => { for (const [method, p] of [ ['GET', '/api/app'], ['GET', '/api/as/actor/app'], ['GET', '/api/catalog'], ['GET', `/api/public-key/${UUID}`], ['GET', `/api/interop/access-need-group/${UUID}`], ['GET', `/api/interop/access-need/${UUID}`], ['GET', '/api/nodeinfo/2.1'], ['POST', '/api/as/actor/app/inbox'], ['POST', `/api/.webhooks/${UUID}`], ['GET', '/.well-known/context.jsonld'], ['GET', '/.well-known/nodeinfo'] ]) { expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} true`); } }); test('the same endpoints outside the base path, and the /.well-known ones under it, are refused', () => { for (const [method, p] of [ ['GET', '/app'], ['GET', '/as/actor/app'], ['GET', '/catalog'], ['GET', `/public-key/${UUID}`], ['POST', '/as/actor/app/inbox'], ['POST', `/.webhooks/${UUID}`], ['GET', '/api/.well-known/context.jsonld'], ['GET', '/api/.well-known/nodeinfo'], ['GET', '/api'], ['GET', '/apiapp'], ['GET', `/apipublic-key/${UUID}`] ]) { expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} false`); } }); test('what is refused at the root is refused under the base path', () => { for (const p of ['/', '/key', `/key/${UUID}`, '/sparql', '/proxy', `/interop/access-grant/${UUID}`, '/as/actor/app/outbox', `/public-key/../key/${UUID}`, `//public-key/${UUID}`]) { expect(`GET /api${p} ${isPublicRequest('GET', `/api${p}`, BASE)}`).toBe(`GET /api${p} false`); } expect(isPublicRequest('PUT', '/api/catalog', BASE)).toBe(false); expect(isPublicRequest('POST', '/api/app', BASE)).toBe(false); }); }); describe('publicSurfaceGate — the middleware', () => { const publicSurfaceGate = createPublicSurfaceGate('/'); const run = req => { let passed; publicSurfaceGate(req, {}, err => { passed = err === undefined ? true : err; }); return passed; }; test('passes a public request, answers 404 to any other (before authentication runs)', () => { expect(run({ method: 'GET', parsedUrl: '/catalog', headers: {} })).toBe(true); const refusal = run({ method: 'GET', parsedUrl: '/key', headers: {} }); expect(refusal).toBeInstanceOf(Error); expect(refusal.code).toBe(404); }); test('a CORS preflight is judged on the method it announces', () => { expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'GET' } })).toBe(true); expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'DELETE' } }).code).toBe(404); expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: {} }).code).toBe(404); }); test('without the gateway-parsed path, the raw URL is used without its query string', () => { expect(run({ method: 'GET', originalUrl: '/catalog?x=1', headers: {} })).toBe(true); expect(run({ method: 'GET', originalUrl: '/key?x=/catalog', headers: {} }).code).toBe(404); }); }); // The mechanism, end to end: a real moleculer-web gateway (the version CoreService uses), with the // REAL @semapps/ldp catch-all route added at runtime as the LDP service adds it, in front of a stand-in // `ldp.api` whose every action answers as if it served the settings dataset. Status codes only. describe('the gate covers routes added at runtime — the LDP catch-all that served the private key', () => { const semappsCore = path.dirname(require.resolve('@semapps/core')); const ApiGateway = require(require.resolve('moleculer-web', { paths: [semappsCore] })); const ldpDir = path.dirname(require.resolve('@semapps/ldp/package.json', { paths: [semappsCore] })); const { default: getCatchAllRoute } = require(path.join(ldpDir, 'dist/routes/getCatchAllRoute.js')); let broker; afterEach(async () => { if (broker) await broker.stop(); broker = undefined; }); async function startGateway(use) { broker = new ServiceBroker({ logger: false }); const answer = () => 'settings dataset content'; broker.createService({ name: 'ldp.api', actions: { get: answer, head: answer, post: answer, put: answer, patch: answer, delete: answer } }); const gateway = broker.createService({ mixins: [ApiGateway], settings: { port: 0, ip: '127.0.0.1', routes: [], ...(use ? { use } : {}) }, methods: { // CoreService's: no credentials → anonymous. authenticate(ctx) { ctx.meta.webId = 'anon'; return Promise.resolve(null); } } }); await broker.start(); await broker.call('api.addRoute', { route: getCatchAllRoute('/', false) }); return `http://127.0.0.1:${gateway.server.address().port}`; } const status = async (base, method, p) => (await fetch(base + p, { method })).status; test('BASELINE (no gate): the catch-all serves the key container to an anonymous caller', async () => { const base = await startGateway(undefined); expect(await status(base, 'GET', '/key')).toBe(200); }); test('with the gate: the key container, the root and any write are 404; a public key is served', async () => { const base = await startGateway([createPublicSurfaceGate('/')]); expect(await status(base, 'GET', '/key')).toBe(404); expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404); expect(await status(base, 'GET', '/')).toBe(404); expect(await status(base, 'GET', `/interop/data-grant/${UUID}`)).toBe(404); expect(await status(base, 'PUT', `/public-key/${UUID}`)).toBe(404); expect(await status(base, 'DELETE', `/interop/access-need/${UUID}`)).toBe(404); expect(await status(base, 'GET', `/public-key/${UUID}`)).toBe(200); expect(await status(base, 'GET', `/interop/access-need/${UUID}`)).toBe(200); }); });