Files
festipod/backend/services/public-surface.test.js
T
Sylvain Duchesne 738d59596a Serve the backend under a base path: one URL per environment, the backend under /api
The test deployment keeps one base URL per environment, as the sibling
ActivityPods apps do, so a pull-request preview lives on a single host: the
app at the root, the backend under /api on the same host, its /.well-known
routes at the origin root, the proxy passing paths through unchanged.

SemApps already works that way — it mounts every route it adds under the base
URL's path and keeps /.well-known at the root — but Festipod's own pieces were
written for a backend at the root of its own origin: its routes (/app,
/as/actor/app, /catalog) were mounted at fixed paths, the public-surface
allow-list matched fixed paths (so under /api every request would have been
refused), and the Solid-OIDC client_id joined the raw request path to the base
URL, which would have repeated the base path (/api/api/...).

base-path.js derives the base path from SEMAPPS_HOME_URL; the routes are
mounted under it; the allow-list matches paths relative to it, the two
/.well-known endpoints against the full path; the client_id is the requested
URI rebuilt from the base URL as written, equal byte for byte to the actor
URI and to what the front end derives. At the root every gate decision is
identical to before (compared over ~8M method/path cases). The rights matrix
probes /.well-known at the backend's origin root.

Verified: bun test (424 pass); the full gate at the root; and the built images
behind a local proxy routing /api and /.well-known unstripped with the Host
header kept — login flow, rights matrix 98/98, revocation.
2026-10-19 16:44:09 +02:00

243 lines
10 KiB
JavaScript

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);
});
});