Files
picpeak/backend/__tests__/services/usageV3.test.js
T

173 lines
12 KiB
JavaScript

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const p = require('../../src/usage/protocol.cjs');
const { expandSnapshot } = require('../../src/usage/expandedSnapshot');
const { capabilityEvidence } = require('../../src/usage/capabilityEvidence');
for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] : [])]) {
describe(`usage.v3 on ${engine}`, () => {
let db, admin, schema, client;
const now = Date.parse('2026-09-06T12:00:00.000Z');
const savedEnv = { ...process.env };
beforeEach(async () => {
if (engine === 'pg') {
admin = knex({ client: 'pg', connection: process.env.PICPEAK_PG_TEST_URL });
schema = `usage_v3_${crypto.randomUUID().replaceAll('-', '')}`;
await admin.schema.createSchema(schema);
db = knex({ client: 'pg', connection: process.env.PICPEAK_PG_TEST_URL, searchPath: [schema] });
} else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
const migrations = path.resolve(__dirname, '../../migrations/core');
for (const file of fs.readdirSync(migrations).filter(name => /^20[1-6]_product_usage/.test(name)).sort())
await require(path.join(migrations, file)).up(db);
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v3' });
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('feature_flags', t => { t.string('key').primary(); t.boolean('value'); });
await db.schema.createTable('events', t => {
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft'); t.boolean('allow_downloads');
});
await db.schema.createTable('photos', t => { t.increments('id'); t.integer('event_id'); t.string('media_type'); t.string('filename'); });
await db.schema.createTable('css_templates', t => { t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); });
await db.schema.createTable('photo_categories', t => { t.increments('id'); t.integer('event_id'); t.boolean('is_folder'); });
await db.schema.createTable('workflows', t => { t.increments('id'); t.boolean('enabled'); });
await db.schema.createTable('transfers', t => {
t.increments('id'); t.string('upload_token'); t.boolean('allow_uploads'); t.timestamp('deleted_at'); t.timestamp('upload_expires_at'); t.timestamp('expires_at');
});
for (const table of ['email_configs', 'mail_accounts'])
await db.schema.createTable(table, t => { t.increments('id'); t.string('smtp_host'); });
await db.schema.createTable('whatsapp_configs', t => { t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token'); });
client = new UsageService(db, { now: () => now, secret: 'v3-test-only-secret'.repeat(3) });
});
afterEach(async () => {
process.env = { ...savedEnv };
await db?.destroy();
if (admin) { await admin.schema.dropSchema(schema, true); await admin.destroy(); admin = null; }
});
test('counts retained gallery/photo records, excluding videos, without loading entities', async () => {
await db('events').insert([{ is_draft: true }, { is_archived: true }, { is_archived: false }]);
await db('photos').insert([
{ event_id: 1, media_type: 'image', filename: 'PRIVATE-original.dng' },
{ event_id: 2, media_type: null, filename: 'PRIVATE-archive.jpg' },
{ event_id: 3, media_type: 'video', filename: 'PRIVATE-video.mov' },
]);
const queries = [];
db.on('query', q => queries.push(q.sql));
const report = await client.snapshot();
expect(report.inventory).toEqual({ galleries: 3, photos: 2 });
expect(Object.keys(report.features)).toHaveLength(86);
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([expect.stringMatching(/select count\(\*\)/)]);
expect(JSON.stringify(report)).not.toContain('PRIVATE');
const identity = p.generateIdentity();
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report, 'usage.v3'), identity, new Date(now));
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
await db('photos').where({ id: 1 }).delete();
await db('events').where({ id: 1 }).delete();
expect((await client.snapshot()).inventory).toEqual({ galleries: 2, photos: 1 });
});
test.each(['usage.v1', 'usage.v2'])('%s consent never collects v3 markers or counts', async (version) => {
await db('product_usage_state').where({ id: 1 }).update({ consent_version: p.CONSENT_VERSIONS[version] });
const queries = [];
db.on('query', q => queries.push(q.sql));
await client.markUsed(['crm_invoice_import', 'photo_admin_marks', 'face_recognition']);
const report = await client.preview();
expect(report).not.toHaveProperty('inventory');
expect(report.features).not.toHaveProperty('crm_invoice_import');
expect(await db('product_usage_markers').pluck('feature')).toEqual(['face_recognition']);
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([]);
expect(queries.some(sql => /count\(\*\)/.test(sql))).toBe(false);
expect((await client.status()).consent_update_available).toBe(true);
});
test('only allowed successful-capability bits survive and preview is read-only', async () => {
const res = { locals: {} };
capabilityEvidence(res, 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import',
'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion', 'PRIVATE@example.test', 'gallery_folders');
await client.markUsed(res.locals.productUsageFeatures);
expect(await db('product_usage_markers').pluck('feature')).toHaveLength(7);
const before = await db('product_usage_markers').orderBy('feature');
const report = await client.preview();
expect(report.inventory).toEqual({ galleries: 0, photos: 0 });
expect(report.features.crm_invoice_import.used).toBe(true);
expect(report.features.gallery_folders).not.toHaveProperty('used');
expect(await db('product_usage_markers').orderBy('feature')).toEqual(before);
await db('product_usage_state').where({ id: 1 }).update({ status: 'deletion_pending' });
await client.markUsed(['face_recognition']);
expect(await db('product_usage_markers').where({ feature: 'face_recognition' })).toHaveLength(0);
});
test('configuration reflects effective modules, applicable folders and unexpired upload permission', async () => {
await db('events').insert({ default_photo_sort: 'capture_date_asc' });
await db('photo_categories').insert({ event_id: 1, is_folder: true });
await db('workflows').insert({ enabled: true });
await db('transfers').insert({ upload_token: 'PRIVATE', allow_uploads: true, upload_expires_at: '2026-09-07T00:00:00.000Z' });
await db('app_settings').insert({ setting_key: 'general_use_original_filenames_for_downloads', setting_value: 'true' });
process.env.STORAGE_BACKEND = 's3'; process.env.STORAGE_AUTO_IMPORT = 'true';
process.env.STORAGE_S3_BUCKET = 'PRIVATE'; process.env.STORAGE_S3_ACCESS_KEY = 'PRIVATE'; process.env.STORAGE_S3_SECRET_KEY = 'PRIVATE';
const snap = flags => expandSnapshot(db, { features: p.emptyFeatures('usage.v1'), flags, used: new Set(), now, version: 'usage.v3' });
const enabled = await snap({ transfers: true, workflows: true, quotes: true, bills: true, incomingInvoices: true });
for (const key of ['gallery_folders', 'transfer_upload_links', 'workflow_automation_enabled', 's3_auto_import', 'gallery_capture_date_sort', 'download_original_filenames', 'crm_invoice_import', 'crm_combined_billing'])
expect(enabled[key].configured).toBe(true);
expect(JSON.stringify(enabled)).not.toContain('PRIVATE');
const disabled = await snap({ transfers: false, workflows: false, quotes: false, bills: true });
for (const key of ['transfer_upload_links', 'workflow_automation_enabled', 'crm_invoice_import', 'crm_combined_billing'])
expect(disabled[key].configured).toBe(false);
await db('transfers').update({ upload_expires_at: '2026-09-06T12:00:00.000Z' });
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(false);
await db('transfers').update({ upload_expires_at: null, expires_at: '2026-09-07T00:00:00.000Z' });
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(true);
await db('transfers').update({ deleted_at: '2026-09-06T11:00:00.000Z' });
expect((await snap({ transfers: true })).transfer_upload_links.configured).toBe(false);
await db('photo_categories').update({ event_id: 999 });
expect((await snap({})).gallery_folders.configured).toBe(false);
});
test('ML recognition is already represented without querying faces or results', async () => {
await db('feature_flags').insert({ key: 'faces', value: true });
await client.markUsed(['face_recognition']);
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: true, used: true });
process.env.PICPEAK_SINGLE_CONTAINER = 'true';
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: false, used: true });
// No faces, people, embeddings or recognition-result tables exist in this fixture.
});
test.each([
[[], false, false], [[true], true, false], [[false], false, true],
[[true, false], true, true], [[null], false, false],
])('v4 measures explicit restrictions independently from legacy allowed downloads: %p', async (values, allowed, restricted) => {
if (values.length) await db('events').insert(values.map(allow_downloads => ({ allow_downloads })));
const queries = [];
db.on('query', q => queries.push(q));
for (const version of ['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4']) {
await db('product_usage_state').where({ id: 1 }).update({ consent_version: p.CONSENT_VERSIONS[version] });
queries.length = 0;
const report = await client.preview();
const downloadQueries = queries.filter(q => /where ["`]allow_downloads["`] =/.test(q.sql));
if (version === 'usage.v4') {
expect(report.features.gallery_downloads_restricted).toEqual({ configured: restricted });
expect(report.features).not.toHaveProperty('gallery_downloads');
expect(report.inventory).toEqual({ galleries: values.length, photos: 0 });
expect(downloadQueries).toHaveLength(1);
expect(downloadQueries[0].sql).toMatch(/select 1 as present/);
expect(Number(downloadQueries[0].bindings[0])).toBe(0);
} else {
expect(report.features).not.toHaveProperty('gallery_downloads_restricted');
if (version === 'usage.v1') expect(downloadQueries).toHaveLength(0);
else {
expect(report.features.gallery_downloads).toEqual({ configured: allowed });
expect(downloadQueries).toHaveLength(1);
expect(Number(downloadQueries[0].bindings[0])).toBe(1);
}
}
const identity = p.generateIdentity();
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report, version), identity, new Date(now));
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
}
});
});
}