c741dc22c5
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.
74 lines
3.8 KiB
JavaScript
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');
|
|
}
|
|
});
|