Files
picpeak/backend/__tests__/services/usageCoverageInventory.test.js
T
Paul Nothaft aa6e5d613f chore(usage): make the capability catalog English-only
features.v2/v3/v4.json carried every name, description and definition
twice, once in English and once in German, inside a file that is source
code, is vendored byte-identically into the collector and is served as
the consented catalog. Source stays English. The German strings already
existed a second time in the frontend locale file, which is what the
consent dialog actually renders (UsageCatalog reads
productUsage.catalog.<key>, never the JSON), so the copy in the catalog
was a duplicate that could only ever drift.

The `de` fields are gone from all three catalogs, their frontend copies
and the inventory definitions; the docs coverage file and
FEATURE_COVERAGE.md list English only. Nothing on the wire changes: the
report schema is derived from the keys, and the catalog's text is not
part of any signature or consent version string.

The coverage test now pins the catalog to the en locale verbatim and
requires the de locale to cover every key and field, without dictating
its wording. The collector holds the same catalog files and needs the
same change to stay byte-identical, plus its German catalog strings
moved into its own locale file; that is filed there.
2026-09-07 08:26:10 +02:00

128 lines
8.3 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const catalog = require('../../src/usage/features.v4.json');
const inventory = require('../../../docs/usage-coverage.v4.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; v4 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(86);
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(87);
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.v4.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 = ['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.observesUse(key)).toBe(true);
});
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, '[email protected]', 'gallery_feedback_likes');
expect(res.locals.productUsageFeatures.sort()).toEqual(['photo_management', 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage'].sort());
});