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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-07 20:03:15 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 69754f8a2c
commit 5c1e38d921
31 changed files with 5398 additions and 292 deletions
+2 -2
View File
@@ -201,8 +201,8 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
});
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v5' }, { consent_version, user: 'PRIVATE' }])
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4', 'usage-consent.v5'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v6' }, { consent_version, user: 'PRIVATE' }])
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
expect(service.command).not.toHaveBeenCalled();
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
@@ -0,0 +1,131 @@
const express = require('express');
const request = require('supertest');
const knex = require('knex');
const { changedFields, settingsChanged } = require('../../src/usage/adoptionEvidence');
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => { req.admin = { id: 1 }; next(); } }));
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (req, res, next) => next() }));
jest.mock('../../src/middleware/requireFeatureFlag', () => ({ requireFeatureFlag: () => (req, res, next) => next() }));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
jest.mock('../../src/services/emailProcessor', () => ({
htmlToText: body => body, wrapEmailHtml: jest.fn(async body => body), buildSignatureTextFor: jest.fn(async () => ''),
}));
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
describe('v5 evidence comes from real edits, not the generic successful-route marker', () => {
let db, app;
const marker = require('../../src/services/productUsageService').markUsed;
const recorded = () => marker.mock.calls.flatMap(([keys]) => keys).filter(key => /_editing$/.test(key));
beforeAll(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
jest.doMock('../../src/database/db', () => ({ db, logActivity: jest.fn(async () => {}) }));
await db.schema.createTable('cms_pages', t => {
t.increments('id'); t.string('slug');
for (const key of ['title_en', 'title_de', 'content_en', 'content_de', 'logo_url', 'external_url']) t.text(key);
t.boolean('use_external_url').defaultTo(false); t.boolean('show_in_footer').defaultTo(true); t.timestamp('updated_at');
});
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.timestamp('updated_at'); });
await db.schema.createTable('email_template_translations', t => {
t.increments('id'); t.integer('template_id'); t.string('language');
for (const key of ['subject', 'body_html', 'body_text']) t.text(key);
t.timestamp('updated_at'); t.timestamp('created_at');
});
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('event_types', t => {
t.increments('id'); for (const key of ['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config']) t.text(key);
t.integer('display_order'); t.boolean('is_active'); t.boolean('is_system'); t.timestamp('updated_at'); t.timestamp('created_at');
});
await db.schema.createTable('events', t => { t.increments('id'); t.integer('created_by'); });
await db.schema.createTable('event_category_order', t => { t.integer('event_id'); t.integer('category_id'); t.integer('position'); });
await db.schema.createTable('photo_categories', t => {
t.increments('id'); t.text('name'); t.text('slug'); t.integer('hero_photo_id'); t.integer('event_id');
t.integer('display_order'); t.boolean('allow_downloads'); t.boolean('is_folder'); t.boolean('is_global');
});
app = express(); app.use(express.json());
app.use(require('../../src/middleware/productUsage').productUsage);
app.use('/cms', require('../../src/routes/adminCMS'));
app.use('/email', require('../../src/routes/adminEmail'));
app.use('/event-types', require('../../src/routes/adminEventTypes'));
app.use('/categories', require('../../src/routes/adminCategories'));
});
beforeEach(async () => {
marker.mockClear();
for (const table of ['cms_pages', 'email_templates', 'email_template_translations', 'app_settings', 'event_types', 'photo_categories', 'events', 'event_category_order']) await db(table).delete();
await db('cms_pages').insert({ slug: 'privacy', title_en: 'Privacy', content_en: 'Seeded content' });
await db('email_templates').insert({ id: 1, template_key: 'PRIVATE-template' });
await db('email_template_translations').insert({ template_id: 1, language: 'en', subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' });
});
afterAll(() => db.destroy());
test('CMS reads, unchanged saves and external content do not imply internal content editing', async () => {
await request(app).get('/cms/pages').expect(200);
await request(app).put('/cms/pages/privacy').send({ content_en: 'Seeded content' }).expect(200);
await request(app).put('/cms/pages/privacy').send({ use_external_url: true, external_url: 'https://example.test/privacy', content_en: 'Other' }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/cms/pages/privacy').send({ use_external_url: false, content_en: 'PRIVATE real content' }).expect(200);
expect(recorded()).toEqual(['cms_content_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toMatch(/PRIVATE|example\.test|privacy/);
});
test('template preview, empty and unchanged saves are excluded; actual content changes count', async () => {
await request(app).post('/email/templates/PRIVATE-template/preview').send({}).expect(200);
await request(app).put('/email/templates/PRIVATE-template').send({ translations: {} }).expect(200);
const translation = { subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' };
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: translation } }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: { ...translation, subject: 'PRIVATE custom subject' } } }).expect(200);
expect(recorded()).toEqual(['email_template_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('failed saves do not count; new nonempty templates do', async () => {
await request(app).put('/email/templates/missing').send({ translations: {} }).expect(404);
await request(app).post('/email/templates').send({ template_key: 'empty', translations: {} }).expect(201);
expect(recorded()).toEqual([]);
await request(app).post('/email/templates').send({ template_key: 'custom', translations: { de: { subject: 'Privat' } } }).expect(201);
expect(recorded()).toEqual(['email_template_editing']);
});
test('seeded event types and categories count only after a real edit, not identical saves', async () => {
await db('event_types').insert({ id: 1, name: 'Wedding', slug_prefix: 'wedding', is_active: true, is_system: true });
await db('photo_categories').insert({ id: 1, name: 'All', slug: 'all', is_global: true, is_folder: false });
await request(app).get('/event-types').expect(200);
await request(app).get('/categories/global').expect(200);
await request(app).put('/event-types/1').send({ name: 'Wedding', is_active: true }).expect(200);
await request(app).put('/categories/1').send({ name: 'All', is_folder: false }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/event-types/1').send({ name: 'PRIVATE event type' }).expect(200);
await request(app).put('/categories/1').send({ name: 'PRIVATE category' }).expect(200);
expect(recorded()).toEqual(['event_type_editing', 'category_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('reordering event types and global categories counts only when the order changes', async () => {
await db('event_types').insert([
{ id: 1, name: 'A', slug_prefix: 'a', display_order: 1, is_active: true, is_system: true },
{ id: 2, name: 'B', slug_prefix: 'b', display_order: 2, is_active: true, is_system: true },
]);
await db('photo_categories').insert([
{ id: 1, name: 'A', slug: 'a', is_global: true, is_folder: false, display_order: 1 },
{ id: 2, name: 'B', slug: 'b', is_global: true, is_folder: false, display_order: 2 },
]);
await db('events').insert({ id: 1 });
await request(app).post('/event-types/reorder').send({ orderedIds: [1, 2] }).expect(200);
await request(app).post('/categories/reorder-global').send({ orderedIds: [1, 2] }).expect(200);
await request(app).delete('/categories/reorder/1').expect(200); // no override to reset
expect(recorded()).toEqual([]);
await request(app).post('/event-types/reorder').send({ orderedIds: [2, 1] }).expect(200);
await request(app).post('/categories/reorder-global').send({ orderedIds: [2, 1] }).expect(200);
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200);
expect(recorded()).toEqual(['event_type_editing', 'category_editing', 'category_editing']);
marker.mockClear();
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200); // same override again
expect(recorded()).toEqual([]);
await request(app).delete('/categories/reorder/1').expect(200);
expect(recorded()).toEqual(['category_editing']);
});
test('settings compare persisted values, not timestamps, JSON order or defaults materialized as rows', async () => {
await db('app_settings').insert({ setting_key: 'theme_config', setting_value: JSON.stringify({ a: 1, b: 2 }) });
expect(await settingsChanged(db, { theme_config: { b: 2, a: 1 } }, ['theme_config'])).toBe(false);
expect(await settingsChanged(db, { theme_config: { b: 2, a: 2 } }, ['theme_config'])).toBe(true);
expect(await settingsChanged(db, { missing: 'fallback' }, ['missing'])).toBe(false);
expect(await settingsChanged(db, { secret: 'PRIVATE' }, ['theme_config'])).toBe(false);
expect(changedFields({ enabled: 1, updated_at: 'old' }, { enabled: true, updated_at: 'new' }, ['enabled'])).toBe(false);
expect(changedFields({ body: '' }, { body: null }, ['body'])).toBe(false);
});
});
@@ -1,8 +1,8 @@
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 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');
@@ -52,22 +52,22 @@ test('all current settings tabs have an explicit scope decision', () => {
}
});
test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descriptions agree', () => {
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(86);
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(87);
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.v4.json')))).toEqual(catalog);
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.
@@ -85,11 +85,11 @@ test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descr
});
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',
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.observesUse(key)).toBe(true);
for (const key of covered) expect(protocol.ALL_FEATURES[key].used).toBeTruthy();
});
test.each([
@@ -7,13 +7,13 @@ const signHistorical = (packet, id) => {
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
};
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4'])('%s receiver compatibility never loosens the PicPeak sender', version => {
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4', 'usage.v5'])('%s receiver compatibility never loosens the PicPeak sender', version => {
test('complete original reports still sign and verify', () => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'report', 1, {
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
features: p.emptyFeatures(version), gallery_layouts: [],
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
}, version);
const envelope = p.signPacket(packet, id, new Date(now));
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
@@ -67,6 +67,14 @@ test('the collector has no way in: no inbound route and no scheduled pull', () =
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');
}
@@ -0,0 +1,82 @@
const knex = require('knex');
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
jest.mock('../../src/services/emailWebhookTransport', () => ({ isEnabled: jest.fn(), send: jest.fn() }));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
describe('template delivery is a coarse transport-acceptance bit', () => {
let db, sendTemplateEmail, queueEmail, processEmailQueue;
const sendMail = jest.fn();
const webhook = require('../../src/services/emailWebhookTransport');
const marker = require('../../src/services/productUsageService').markUsed;
beforeAll(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
jest.doMock('../../src/database/db', () => ({ db }));
await db.schema.createTable('email_configs', t => {
t.increments('id'); for (const key of ['smtp_host', 'smtp_user', 'smtp_pass', 'from_name', 'from_email']) t.string(key);
t.integer('smtp_port'); t.boolean('smtp_secure'); t.boolean('tls_reject_unauthorized');
});
await db('email_configs').insert({ smtp_host: 'smtp.example.test', smtp_port: 587, from_email: '[email protected]' });
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.text('subject'); t.text('body_html'); });
await db('email_templates').insert({ template_key: 'PRIVATE-template', subject: 'PRIVATE subject', body_html: '<p>PRIVATE body</p>' });
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('email_queue', t => {
t.increments('id'); t.integer('event_id'); t.integer('campaign_id'); t.string('recipient_email'); t.string('email_type');
t.text('email_data'); t.string('status'); t.integer('retry_count'); t.text('error_message'); t.text('rendered_html');
t.timestamp('scheduled_at'); t.timestamp('created_at'); t.timestamp('sent_at');
});
require('nodemailer').createTransport.mockReturnValue({ sendMail, verify: jest.fn(async () => true) });
({ sendTemplateEmail, queueEmail, processEmailQueue } = require('../../src/services/emailProcessor'));
});
beforeEach(() => {
marker.mockClear(); marker.mockResolvedValue(); webhook.isEnabled.mockReturnValue(false);
sendMail.mockReset(); sendMail.mockResolvedValue({ messageId: 'PRIVATE-message', accepted: ['[email protected]'] });
});
afterAll(() => db.destroy());
const send = options => sendTemplateEmail('[email protected]', 'PRIVATE-template', { __language: 'en' }, options);
test('successful real SMTP send transmits only the fixed capability key', async () => {
await send();
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('rejected recipients, failed sends, missing templates and explicit tests do not count', async () => {
await send({ usageEligible: false });
sendMail.mockResolvedValueOnce({ messageId: 'private', accepted: [] }); await send();
sendMail.mockRejectedValueOnce(new Error('mail failed')); await expect(send()).rejects.toThrow('mail failed');
await expect(sendTemplateEmail('[email protected]', 'missing', { __language: 'en' })).rejects.toThrow('not found');
expect(marker).not.toHaveBeenCalled();
});
test('a queued test message keeps its exclusion through the queue processor; a queued real one counts', async () => {
await queueEmail(null, '[email protected]', 'PRIVATE-template', { __language: 'en' }, { usageEligible: false });
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(1);
expect(marker).not.toHaveBeenCalled();
await queueEmail(null, '[email protected]', 'PRIVATE-template', { __language: 'en' });
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(2);
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
expect(await db('email_queue').where('status', 'sent').count({ n: '*' }).first()).toMatchObject({ n: 2 });
});
test('a workflow test run (engine.testRun, __test) queues a test message; a real run counts', async () => {
require('../../src/services/workflows/actions');
const sendEmail = require('../../src/services/workflows/registry').getAction('send_email');
const ctx = (vars) => ({ node: { config: { to: '[email protected]', emailType: 'PRIVATE-template', recipientClass: 'admin' } }, vars });
await sendEmail(ctx({ __test: true, emailData: { __language: 'en' } }));
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(1);
expect(marker).not.toHaveBeenCalled();
await sendEmail(ctx({ emailData: { __language: 'en' } }));
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(2);
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
});
test('webhook success counts; failure does not; marker failure never retries successful mail', async () => {
webhook.isEnabled.mockReturnValue(true); webhook.send.mockResolvedValue({ messageId: 'private' });
marker.mockRejectedValueOnce(new Error('usage unavailable'));
await expect(send()).resolves.toMatchObject({ success: true });
expect(webhook.send).toHaveBeenCalledTimes(1);
marker.mockClear(); webhook.send.mockRejectedValueOnce(new Error('webhook failed'));
await expect(send()).rejects.toThrow('webhook failed');
expect(marker).not.toHaveBeenCalled();
});
});