Files
picpeak/backend/__tests__/services/usageOutboundOnly.test.js
T
Paul Nothaft 5c1e38d921 feat(usage): distinguish real edits and template delivery with v5 consent (#1339)
* feat(usage): distinguish real edits and template delivery with v5 consent

* fix(usage): exclude queued test messages and count reorders as edits

- queueEmail carries usageEligible: false into email_data and the queue
  processor passes it on, so the dev tools' send-test-email no longer
  records email_template_delivery once the worker sends it.
- event-types/reorder and categories/reorder-global compare the persisted
  order before and after and record the v5 edit markers only when it
  changed, matching the display_order edit already counted on PUT.
- normalized() builds arrays with Array.from so a row array from the sqlite
  binding compares equal under Jest's separate realm.

* fix(usage): cover per-gallery category order and workflow test runs

- categories/reorder records category_editing when an event's override
  changes; reorder/:eventId records it when an override was actually
  removed.
- send_email and the collections handoff pass usageEligible: false for a
  workflow test run (engine.testRun sets __test), so a non-dry test send is
  not counted as template delivery.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-07 20:03:15 +02:00

82 lines
4.2 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;
if (entry.name === 'emailProcessor.js') {
// v5 explicitly consents to one local background-mail bit, never a send
// to the collector. No mail details may be passed into the usage API.
const email = fs.readFileSync(path.join(dir, entry.name), 'utf8');
expect(email).toContain(".markUsed(['email_template_delivery'])");
expect(email).not.toMatch(/productUsageService'\)\.(?:tick|enable|command|deliver)/);
continue;
}
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
.not.toContain('productUsageService');
}
});