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>
128 lines
8.5 KiB
JavaScript
128 lines
8.5 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const catalog = require('../../src/usage/features.v5.json');
|
|
const inventory = require('../../../docs/usage-coverage.v5.json');
|
|
const protocol = require('../../src/usage/schema.cjs');
|
|
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
|
|
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
|
|
|
|
test('every route family and literal route declaration has an explicit privacy decision', () => {
|
|
const root = path.resolve(__dirname, '../../src/routes');
|
|
const actual = {};
|
|
function walk(dir) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.name === '__tests__') continue;
|
|
const file = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(file);
|
|
else if (entry.name.endsWith('.js')) {
|
|
const source = fs.readFileSync(file, 'utf8');
|
|
actual[path.relative(root, file)] = [...source.matchAll(/router\.(get|post|put|patch|delete)\(\s*(['"])([^'"]+)\2/g)]
|
|
.map((m) => `${m[1].toUpperCase()} ${m[3]}`);
|
|
}
|
|
}
|
|
}
|
|
walk(root);
|
|
expect(Object.keys(inventory.route_families).sort()).toEqual(Object.keys(actual).sort());
|
|
for (const [file, decision] of Object.entries(inventory.route_families)) {
|
|
expect(decision.reason.length).toBeGreaterThan(30);
|
|
expect(decision.route_signatures).toEqual(actual[file]);
|
|
for (const signal of decision.signals) expect(catalog.features[signal]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('all flags and catalog capabilities have a documented decision', () => {
|
|
const source = fs.readFileSync(path.resolve(__dirname, '../../src/routes/adminFeatureFlags.js'), 'utf8');
|
|
const array = source.match(/const KNOWN_FLAGS = \[([\s\S]*?)\];/)[1].replace(/\/\/[^\n]*/g, '');
|
|
const flags = [...array.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
expect(Object.keys(inventory.feature_flags).sort()).toEqual(flags.sort());
|
|
for (const key of protocol.FEATURE_KEYS)
|
|
expect(Object.values(inventory.route_families).some((family) => family.signals.includes(key))).toBe(true);
|
|
expect(inventory.configuration_only.sort()).toEqual(protocol.FEATURE_KEYS.filter((key) => !protocol.observesUse(key)).sort());
|
|
});
|
|
|
|
test('all current settings tabs have an explicit scope decision', () => {
|
|
const source = fs.readFileSync(path.resolve(__dirname, '../../../frontend/src/pages/admin/SettingsPage.tsx'), 'utf8');
|
|
const union = source.match(/type TabType =([\s\S]*?);/)[1].replace(/\/\/[^\n]*/g, '');
|
|
const tabs = [...union.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
expect(Object.keys(inventory.settings_tabs).sort()).toEqual(tabs.sort());
|
|
for (const entry of Object.values(inventory.settings_tabs)) {
|
|
expect(entry.reason.length).toBeGreaterThan(20);
|
|
for (const key of entry.signals) expect(catalog.features[key]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
test('v1/v2/v3 wire validation is immutable; v5 catalog, UI and translated descriptions agree', () => {
|
|
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
|
|
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
|
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
|
|
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
|
|
expect(protocol.FEATURE_KEYS).toHaveLength(87);
|
|
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
|
|
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
|
|
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
|
|
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
|
|
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(94);
|
|
expect(protocol.ALL_FEATURE_KEYS).toContain('gallery_downloads');
|
|
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
|
expect(inventory.configuration_only).toHaveLength(23);
|
|
const frontend = path.resolve(__dirname, '../../../frontend');
|
|
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v5.json')))).toEqual(catalog);
|
|
// The catalog is source, and source is English only: its strings are the
|
|
// en locale verbatim. Every other language lives in its locale file and
|
|
// must cover every key and field, but says whatever its translator chose.
|
|
const english = JSON.parse(fs.readFileSync(path.join(frontend, 'src/i18n/locales/en.json'))).productUsage.catalog;
|
|
for (const [key, value] of Object.entries(catalog.features)) {
|
|
expect(Object.keys(value.name)).toEqual(['en']);
|
|
expect(english[key]).toEqual({ name: value.name.en, configured: value.configured.en, ...(value.used ? { used: value.used.en } : {}) });
|
|
}
|
|
const german = JSON.parse(fs.readFileSync(path.join(frontend, 'src/i18n/locales/de.json'))).productUsage.catalog;
|
|
for (const [key, value] of Object.entries(catalog.features)) {
|
|
expect(Object.keys(german[key] || {}).sort()).toEqual(Object.keys(english[key]).sort());
|
|
for (const field of Object.keys(english[key])) expect(typeof german[key][field]).toBe('string');
|
|
void value;
|
|
}
|
|
});
|
|
|
|
test('every used field has either a fixed route rule or explicit trusted success evidence', () => {
|
|
const explicit = ['cms_content_editing', 'email_template_editing', 'email_template_delivery', 'branding_editing', 'seo_editing', 'event_type_editing', 'category_editing', 'custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
|
|
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration', 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import', 'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion'];
|
|
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
|
|
expect(protocol.FEATURE_KEYS.filter((key) => protocol.observesUse(key)).filter((key) => !covered.has(key))).toEqual([]);
|
|
for (const key of covered) expect(protocol.ALL_FEATURES[key].used).toBeTruthy();
|
|
});
|
|
|
|
test.each([
|
|
['POST', '/events', 'galleries'], ['POST', '/events/123/publish', 'galleries'],
|
|
['POST', '/photos/repair-dimensions', 'photo_processing'], ['GET', '/events/123/photos/456/download', 'photo_exports'],
|
|
['PUT', '/events/123/slideshow', 'slideshow'], ['POST', '/expenses/inbound', 'accounting_incoming_invoices'],
|
|
['POST', '/expenses', 'accounting_expenses'], ['GET', '/tax-report/csv', 'accounting_tax_report'],
|
|
['POST', '/deals/123/installment-plan', 'crm_installments'], ['GET', '/ledger/export', 'accounting_ledger'],
|
|
['POST', '/quotes/presets', 'document_templates'], ['PUT', '/cms/pages/home', 'cms'],
|
|
['POST', '/webhooks/123/test', 'webhooks'], ['POST', '/webhooks/123/deliveries/456/replay', 'webhooks'],
|
|
['POST', '/email/send', 'messaging'], ['PUT', '/feedback/feedback/123/approve', 'feedback_moderation'],
|
|
['GET', '/events/123/guests/export-all', 'guest_management'], ['POST', '/backup/picpeak/import', 'portable_backup'],
|
|
['PUT', '/roles/123', 'admin_management'], ['POST', '/newsletters/123/queue', 'newsletters']
|
|
])('fixed allowlist recognizes %s %s', (method, url, expected) => {
|
|
expect(capabilityKeys(method, url)).toContain(expected);
|
|
expect(JSON.stringify(capabilityKeys(method, url))).not.toContain('123');
|
|
});
|
|
|
|
test.each([
|
|
['GET', '/events/faces/health'], ['GET', '/photos/repair-dimensions/status'],
|
|
['POST', '/events/123/validate-rename'], ['POST', '/photos/123/chunked-upload/init'],
|
|
['POST', '/photos/123/chunked-upload/456/chunk/0'], ['GET', '/dashboard/health'],
|
|
['GET', '/customers'], ['GET', '/email/queue'], ['POST', '/email/flush-queue'],
|
|
['POST', '/newsletters/123/recipients/resolve'], ['POST', '/newsletters/123/preview'],
|
|
['POST', '/users/123/reset-password'], ['PUT', '/settings/security'],
|
|
['POST', '/gallery/a/feedback'], ['POST', '/public/newsletter/unsubscribe'],
|
|
['POST', '/customer/quotes/123/accept'], ['POST', '/usage/consent']
|
|
])('no v2 observation for excluded %s %s', (method, url) => expect(capabilityKeys(method, url)).toEqual([]));
|
|
|
|
test('trusted upload evidence retains only constant keys and configuration-only use cannot be recorded', () => {
|
|
const res = { locals: {} };
|
|
acceptedUpload(res, { video: true, raw: true, s3: true });
|
|
capabilityEvidence(res, 'PRIVATE-user@example.test', 'gallery_feedback_likes');
|
|
expect(res.locals.productUsageFeatures.sort()).toEqual(['photo_management', 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage'].sort());
|
|
});
|