Files
picpeak/backend/__tests__/services/usageOutboundOnly.test.js
T
Paul Nothaft c741dc22c5 docs(usage): state in the consent dialog that the connection only runs outwards
The dialog described what is sent and where it goes, but never said which way
the connection runs. That is the part an operator is actually being asked to
accept: opening an outbound path to someone else's service.

PicPeak sends and never pulls. One place in the service reaches the network,
it is a POST, and it requests exactly two paths — /api/envelopes, and
/api/participant/lookup only when an operator asks for their own export. No
scheduled job contacts the collector; the daily rollup is driven solely by an
authenticated admin hitting /activity. There is no route the collector could
call, and redirect: 'error' means it cannot even point a request somewhere
else. From a reply only the acknowledgement for the packet just sent is read,
with every field compared against that packet before it is accepted; the
stored copy drops the session token and no read path hands it back to the UI.
A requested export is streamed to the operator as a file and never
interpreted.

The consequence is why it belongs in the consent text and not only in the
docs: this channel cannot deliver code, configuration or content into an
installation, not even from a collector that has been taken over. It is a
security property by design rather than by convention.

usageOutboundOnly.test.js guards it by source inspection rather than
behaviour, because a behavioural test only proves that today's calls behave.
It fails the moment someone adds a second fetch, a poll for messages, a
scheduled pull, or a public route touching the usage service — verified by
injecting each of those.
2026-09-06 17:58:19 +02:00

74 lines
3.8 KiB
JavaScript

/**
* The consent dialog tells the operator that this connection only ever runs
* outwards: PicPeak sends, and reads nothing back but the acknowledgement for
* the packet it just sent. That is a security claim — it is the reason a
* compromised collector cannot use this path to push code, configuration or
* content into an installation — so it is guarded here rather than left to
* review.
*
* These are source-inspection assertions on purpose. A behavioural test only
* proves the calls that exist today behave; this fails the moment someone adds
* a "check the collector for messages" fetch, a polling job, or an endpoint the
* collector could call.
*/
const fs = require('fs');
const path = require('path');
const SRC = path.resolve(__dirname, '../../src');
const service = fs.readFileSync(path.join(SRC, 'usage/UsageService.js'), 'utf8');
const route = fs.readFileSync(path.join(SRC, 'routes/adminUsage.js'), 'utf8');
const server = fs.readFileSync(path.resolve(__dirname, '../../server.js'), 'utf8');
test('the collector is contacted from exactly one place, and only by POST', () => {
// One transport helper. Anything else reaching for the network here would
// bypass the size cap, the redirect ban and the timeout as well.
const callSites = service.match(/this\.fetch\(/g) || [];
expect(callSites).toHaveLength(1);
const post = service.slice(service.indexOf('async post('));
expect(post).toContain('method: \'POST\'');
// A redirect is an instruction from the collector about where to go next.
expect(post).toContain('redirect: \'error\'');
expect(post).toContain('AbortSignal.timeout(');
});
test('only the two known collector paths are ever requested', () => {
const paths = [...service.matchAll(/this\.post\(\s*'([^']+)'/g)].map((m) => m[1]);
expect(paths.sort()).toEqual(['/api/envelopes', '/api/participant/lookup']);
});
test('nothing is read from a reply except the acknowledgement, checked field by field', () => {
// Every field of the receipt is compared against the packet that was sent.
for (const field of ['packet_id', 'installation_id', 'packet_digest', 'action', 'sequence', 'status'])
expect(service).toMatch(new RegExp(`receipt\\.${field} !==`));
expect(service).toContain('throw new Error(\'Invalid collector receipt\')');
// The stored copy drops the one value that is not an echo of what we sent,
// and no read path hands it back out again.
expect(service).toContain('delete storedReceipt.session_token');
expect(service).not.toMatch(/last_receipt:\s*state\.last_receipt/);
const status = service.slice(service.indexOf('async status()'), service.indexOf('async locked('));
expect(status).not.toContain('last_receipt');
});
test('the collector has no way in: no inbound route and no scheduled pull', () => {
// Every usage route is mounted behind adminAuth on the admin surface.
expect(server).toContain('app.use(\'/api/admin/usage\', require(\'./src/routes/adminUsage\'))');
expect(route).toContain('router.use(adminAuth)');
// No public/gallery/webhook mount for anything usage-related.
const publicMounts = [...server.matchAll(/app\.use\('\/api\/(public|gallery|customer|invite)[^']*',[^\n]*\)/g)]
.map((m) => m[0]);
for (const mount of publicMounts) expect(mount).not.toMatch(/[Uu]sage/);
// Nothing schedules a collector call; the daily rollup is driven only by an
// authenticated admin hitting /activity.
expect(service).not.toMatch(/setInterval|setTimeout\s*\(\s*\(\)\s*=>\s*this\.tick/);
const dir = path.join(SRC, 'services');
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
if (entry.name === 'productUsageService.js') continue;
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
.not.toContain('productUsageService');
}
});