feat: expand opt-in capability coverage with versioned consent
This commit is contained in:
@@ -51,6 +51,7 @@ maybe('product usage on Postgres', () => {
|
||||
await require('../../migrations/core/202_product_usage_cancel_requested').up(db);
|
||||
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
|
||||
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
|
||||
await require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
@@ -112,6 +113,7 @@ maybe('product usage on Postgres', () => {
|
||||
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
|
||||
expect(cols.sequence).toBeDefined();
|
||||
expect(cols.privacy_receipts).toBeDefined();
|
||||
expect(cols.consent_version).toBeDefined();
|
||||
});
|
||||
|
||||
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
||||
@@ -125,6 +127,27 @@ maybe('product usage on Postgres', () => {
|
||||
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
|
||||
});
|
||||
|
||||
it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => {
|
||||
const migration = require('../../migrations/core/205_product_usage_consent_version');
|
||||
await migration.up(db); await migration.up(db);
|
||||
const svc = service();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await svc.markUsed(['video_uploads']);
|
||||
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||
expect((await svc.status()).schema_version).toBe('usage.v1');
|
||||
await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' });
|
||||
await db('feature_flags').insert({ key: 'quotes', value: true });
|
||||
await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' });
|
||||
await svc.markUsed(['video_uploads', 'gallery_downloads']);
|
||||
const report = await svc.snapshot();
|
||||
expect(Object.keys(report.features)).toHaveLength(73);
|
||||
expect(report.features.video_uploads).toEqual({ configured: true, used: true });
|
||||
expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false });
|
||||
expect(report.features.gallery_downloads).toEqual({ configured: false });
|
||||
expect(report.features.crm.configured).toBe(true);
|
||||
expect(report.features.api_integration.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 });
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
|
||||
@@ -41,6 +41,7 @@ jest.mock('../../src/services/productUsageService', () =>
|
||||
);
|
||||
const service = require('../../src/services/productUsageService');
|
||||
const { productUsage } = require('../../src/middleware/productUsage');
|
||||
const { productUsageApi } = require('../../src/middleware/productUsage');
|
||||
const SECRET = 'usage-auth-test-secret-not-a-live-credential';
|
||||
const token = (type, id = 1) =>
|
||||
jwt.sign({ type, id }, SECRET, {
|
||||
@@ -102,10 +103,27 @@ beforeAll(async () => {
|
||||
afterAll(() => mockDb.destroy());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('scoped API use records only its fixed v2 capability and never triggers a report', () => {
|
||||
const simulate = (admin, apiToken, statusCode) => {
|
||||
const res = new (require('events').EventEmitter)(); res.statusCode = statusCode;
|
||||
productUsageApi({ admin, apiToken, body: { user: '[email protected]' } }, res, () => {});
|
||||
res.emit('finish');
|
||||
};
|
||||
simulate(null, { id: 99 }, 200);
|
||||
simulate({ id: 42 }, null, 200);
|
||||
simulate({ id: 42 }, { id: 99 }, 403);
|
||||
expect(service.markUsed).not.toHaveBeenCalled();
|
||||
simulate({ id: 42 }, { id: 99 }, 200);
|
||||
expect(service.markUsed).toHaveBeenCalledWith(['api_integration'], { legacyFeatures: [] });
|
||||
expect(service.tick).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toMatch(/PRIVATE|42|99/);
|
||||
});
|
||||
|
||||
const ROUTES = [
|
||||
['get', '/'],
|
||||
['post', '/activity'],
|
||||
['post', '/enable'],
|
||||
['post', '/consent'],
|
||||
['post', '/disable'],
|
||||
['post', '/retry'],
|
||||
['post', '/dismiss'],
|
||||
@@ -162,8 +180,9 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
|
||||
const { EventEmitter } = require('events');
|
||||
const simulate = (path, admin, statusCode) => {
|
||||
const res = new EventEmitter();
|
||||
res.locals = {};
|
||||
res.statusCode = statusCode;
|
||||
productUsage({ path, admin }, res, () => {});
|
||||
productUsage({ path, method: 'POST', admin }, res, () => {});
|
||||
res.emit('finish');
|
||||
};
|
||||
simulate('/gallery/example', null, 200);
|
||||
@@ -180,6 +199,15 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||
});
|
||||
|
||||
test('consent upgrade accepts exactly the explicit v2 choice, never extra fields', async () => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v2', 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')}`)
|
||||
.send({ consent_version: 'usage-consent.v2' }).expect(200);
|
||||
expect(service.command).toHaveBeenCalledWith('consent', { consent_version: 'usage-consent.v2' });
|
||||
});
|
||||
|
||||
test('only a backup that writes to the configured destination flags S3', () => {
|
||||
// /database-backup/* and /backup/picpeak/export produce a local file, so
|
||||
// they must not imply S3 use just because S3 is the configured destination.
|
||||
@@ -187,8 +215,9 @@ test('only a backup that writes to the configured destination flags S3', () => {
|
||||
const simulate = (pathname) => {
|
||||
service.markUsed.mockClear();
|
||||
const res = new (require('events').EventEmitter)();
|
||||
res.locals = {};
|
||||
res.statusCode = 200;
|
||||
productUsage({ path: pathname, admin: { id: 1 } }, res, () => {});
|
||||
productUsage({ path: pathname, method: pathname.endsWith('/export') ? 'GET' : 'POST', admin: { id: 1 } }, res, () => {});
|
||||
res.emit('finish');
|
||||
seen.push([pathname, service.markUsed.mock.calls[0]?.[1]?.destinationBackup]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const catalog = require('../../src/usage/features.v2.json');
|
||||
const inventory = require('../../../docs/usage-coverage.v2.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 wire validation is immutable; catalog, UI and translated descriptions agree', () => {
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
|
||||
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
||||
expect(protocol.FEATURE_KEYS).toHaveLength(73);
|
||||
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
||||
expect(inventory.configuration_only).toHaveLength(17);
|
||||
const frontend = path.resolve(__dirname, '../../../frontend');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v2.json')))).toEqual(catalog);
|
||||
for (const lang of ['en', 'de']) {
|
||||
const translated = JSON.parse(fs.readFileSync(path.join(frontend, `src/i18n/locales/${lang}.json`))).productUsage.catalog;
|
||||
for (const [key, value] of Object.entries(catalog.features)) {
|
||||
expect(translated[key]).toEqual({ name: value.name[lang], configured: value.configured[lang], ...(value.used ? { used: value.used[lang] } : {}) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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'];
|
||||
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
|
||||
expect(protocol.FEATURE_KEYS.filter(protocol.observesUse).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());
|
||||
});
|
||||
@@ -19,7 +19,7 @@ const SECRET = 'z'.repeat(48);
|
||||
// during signing, so the packet would never reach the collector for reasons
|
||||
// unrelated to what the test is checking.
|
||||
function validReport() {
|
||||
const { FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
|
||||
const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
|
||||
return {
|
||||
picpeak_version: '3.0.0',
|
||||
report_date: '2026-09-05',
|
||||
@@ -40,6 +40,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);
|
||||
@@ -173,7 +174,8 @@ describe('withdrawal during an in-flight activation', () => {
|
||||
{ installation_id: identity.installation_id },
|
||||
'report',
|
||||
2,
|
||||
validReport()
|
||||
validReport(),
|
||||
'usage.v1'
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user