5c1e38d921
* 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>
82 lines
4.2 KiB
JavaScript
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');
|
|
}
|
|
});
|