feat: expand opt-in capability coverage with versioned consent

This commit is contained in:
Paul Nothaft
2026-09-06 00:56:58 +02:00
parent 5d31b61c8d
commit a7382591bf
32 changed files with 6250 additions and 164 deletions
@@ -10,6 +10,7 @@
*/
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const { FEATURE_KEYS, CATALOG, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs');
async function bootDb() {
const db = knex({
@@ -20,6 +21,7 @@ async function bootDb() {
await db.schema.createTable('product_usage_state', (t) => {
t.integer('id').primary();
t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
@@ -193,3 +195,110 @@ describe('S3 use is only implied by backups that write to the destination', () =
expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']);
});
});
describe('v2 technical configuration and privacy boundaries', () => {
let db;
let savedEnv;
beforeEach(() => { savedEnv = { ...process.env }; });
afterEach(async () => { if (db) await db.destroy(); db = null; process.env = savedEnv; });
async function expandedDb() {
db = await bootDb();
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
await db.schema.alterTable('events', (t) => {
for (const column of ['allow_user_uploads', 'allow_downloads', 'client_access_enabled', 'watermark_downloads', 'reveal_mode', 'download_resolution_picker_enabled', 'disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
t.string('protection_level'); t.timestamp('expires_at'); t.string('event_name'); t.string('customer_email');
});
for (const table of ['email_configs', 'mail_accounts']) await db.schema.alterTable(table, (t) => {
t.boolean('enabled'); t.string('imap_host'); t.string('imap_user'); t.string('imap_pass');
});
await db.schema.createTable('event_feedback_settings', (t) => {
t.increments('id'); t.boolean('feedback_enabled'); t.string('identity_mode');
for (const col of ['allow_likes', 'allow_ratings', 'allow_comments', 'allow_favorites', 'allow_reactions', 'allow_color_labels']) t.boolean(col);
});
await db.schema.createTable('api_tokens', (t) => { t.increments('id'); t.timestamp('revoked_at'); t.timestamp('expires_at'); t.string('token_hash'); });
await db.schema.createTable('webhooks', (t) => { t.increments('id'); t.boolean('active'); t.string('url'); t.string('secret'); });
return service(db, { now: () => Date.parse('2026-09-06T12:00:00.000Z'), version: '3.124.1-beta.0' });
}
it('produces all 73 closed booleans, never exposing sensitive values or configuration-only used', async () => {
const client = await expandedDb();
const flags = [...new Set(Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)), 'incomingMail', 'whatsapp'];
await db('feature_flags').insert([...new Set(flags)].map((key) => ({ key, value: true })));
const settings = {
general_allowed_file_types: 'jpg,dng,mp4', general_public_site_enabled: true,
database_backup_enabled: true, backup_destination_type: 's3', backup_s3_bucket: 'PRIVATE-bucket',
oidc_enabled: true, oidc_issuer_url: 'https://PRIVATE.example.test', oidc_client_id: 'PRIVATE-client',
general_custom_css: '.PRIVATE { color:red; }'
};
await db('app_settings').insert(Object.entries(settings).map(([setting_key, value]) => ({ setting_key, setting_value: JSON.stringify(value) })));
await db('events').insert({
event_name: 'PRIVATE PERSON', customer_email: '[email protected]', external_path: '/PRIVATE/path',
color_theme: JSON.stringify({ galleryLayout: 'gallery-story', privateName: 'PRIVATE' }),
allow_user_uploads: true, allow_downloads: true, client_access_enabled: true, watermark_downloads: true,
reveal_mode: true, download_resolution_picker_enabled: true, disable_right_click: true,
expires_at: '2028-01-01T00:00:00.000Z'
});
await db('event_feedback_settings').insert({ feedback_enabled: true, identity_mode: 'guest',
allow_likes: true, allow_ratings: true, allow_comments: true, allow_favorites: true, allow_reactions: true, allow_color_labels: true });
await db('email_configs').insert({ smtp_host: 'PRIVATE-host', imap_host: 'PRIVATE-host', imap_user: 'PRIVATE-user', imap_pass: 'PRIVATE-secret' });
await db('whatsapp_configs').insert({ enabled: true, phone_number_id: 'PRIVATE-phone', access_token: 'PRIVATE-token' });
await db('api_tokens').insert({ token_hash: 'PRIVATE-token', expires_at: '2028-01-01T00:00:00.000Z' });
await db('webhooks').insert({ active: true, url: 'https://PRIVATE.example.test', secret: 'PRIVATE-secret' });
Object.assign(process.env, { STORAGE_BACKEND: 's3', STORAGE_S3_BUCKET: 'PRIVATE', STORAGE_S3_ACCESS_KEY: 'PRIVATE', STORAGE_S3_SECRET_KEY: 'PRIVATE', EMAIL_WEBHOOK_URL: 'https://PRIVATE.example.test', EMAIL_WEBHOOK_SECRET: 'PRIVATE' });
delete process.env.PICPEAK_SINGLE_CONTAINER;
await client.markUsed([...FEATURE_KEYS, '[email protected]']);
const report = await client.snapshot();
expect(Object.keys(report.features)).toEqual(FEATURE_KEYS);
for (const [key, definition] of Object.entries(CATALOG.features)) {
expect(report.features[key].configured).toBe(true);
if (definition.used) expect(report.features[key].used).toBe(true);
else expect(report.features[key]).toEqual({ configured: true });
}
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(56);
expect(JSON.stringify(report)).not.toContain('PRIVATE');
const identity = generateIdentity();
const envelope = signPacket(makePacket(identity, 'report', 1, report), identity, new Date(report.generated_at));
expect(verifyEnvelope(envelope, Date.parse(report.generated_at))).toEqual(envelope.packet);
});
it('applies parent/AIO gates and does not confuse disabled or expired config with availability', async () => {
const client = await expandedDb();
process.env.PICPEAK_SINGLE_CONTAINER = 'yes';
await db('feature_flags').insert(['bills', 'incomingInvoices', 'expenses', 'taxReport', 'faces', 'incomingMail'].map((key) => ({ key, value: true })));
await db('api_tokens').insert([
{ revoked_at: '2026-01-01', expires_at: null },
{ revoked_at: null, expires_at: '2026-01-01' }
]);
await db('webhooks').insert({ active: false });
await db('mail_accounts').insert({ enabled: false, imap_host: 'PRIVATE', imap_user: 'PRIVATE', imap_pass: 'PRIVATE' });
await db('event_feedback_settings').insert({ feedback_enabled: false, identity_mode: 'guest', allow_likes: true });
await db('events').insert({ allow_user_uploads: false, reveal_mode: true });
const report = await client.snapshot();
for (const key of ['crm_invoices', 'accounting_incoming_invoices', 'accounting_expenses', 'accounting_tax_report', 'face_recognition', 'api_integration', 'webhooks', 'incoming_mail', 'gallery_feedback_likes', 'gallery_guest_accounts', 'gallery_reveal']) expect(report.features[key].configured).toBe(false);
expect(report.features.galleries).toEqual({ configured: true, used: false });
expect(report.features.admin_management.configured).toBe(true);
expect(report.features.analytics_dashboard.configured).toBe(true);
});
it('handles missing optional tables, global protection defaults and durable consent boundaries', async () => {
db = await bootDb();
const client = service(db);
await db('app_settings').insert({ setting_key: 'default_protection_level', setting_value: '"enhanced"' });
await client.markUsed(FEATURE_KEYS);
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
await db('product_usage_state').update({ status: 'active' });
await client.markUsed(['video_uploads', 'api_integration']);
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
expect(Object.keys((await client.snapshot()).features)).toHaveLength(19);
await db('product_usage_state').update({ consent_version: 'usage-consent.v2' });
const report = await client.snapshot();
expect(report.features.gallery_image_protection).toEqual({ configured: true });
expect(report.features.api_integration).toEqual({ configured: false, used: false });
expect(report.features.document_templates).toEqual({ configured: false, used: false });
await client.markUsed(['video_uploads', 'gallery_downloads']);
expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']);
await db('product_usage_state').update({ status: 'deletion_pending' });
await client.markUsed(['api_integration']);
expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']);
});
});