ff23efec81
#1359 independently added 211_revocations_without_expiry.js against the same main baseline. Renumbering this one to 212 keeps the migrations directory sequentially numbered once both land, regardless of merge order. No functional change — same up()/down(), same column.
299 lines
13 KiB
JavaScript
299 lines
13 KiB
JavaScript
/**
|
|
* PostgreSQL checks for product usage (#1110).
|
|
*
|
|
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
|
|
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_usage_pg_test" \
|
|
* npx jest __tests__/integration/productUsagePg.test.js
|
|
*
|
|
* What SQLite cannot answer:
|
|
* - `cancel_seq` and `sequence` are bigint, and node-postgres returns bigint
|
|
* as a STRING. The withdrawal guard compares that value, so a `'1' !== 1`
|
|
* slip would let an activation complete after an opt-out — and SQLite,
|
|
* which hands back a number, would never show it.
|
|
* - booleans are real booleans here, not 0/1, which is what every
|
|
* `configured` signal in a report is built from.
|
|
* - markUsed takes SELECT ... FOR UPDATE on this engine only.
|
|
*/
|
|
const knex = require('knex');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
|
|
|
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
|
const maybe = PG_URL ? describe : describe.skip;
|
|
|
|
maybe('product usage on Postgres', () => {
|
|
let db;
|
|
let UsageService;
|
|
|
|
beforeAll(async () => {
|
|
// Its own schema, not `public`. CI hands every gated suite the same
|
|
// PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
|
|
// picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
|
|
// and `app_settings` there. Sharing that would have made all three
|
|
// intermittently destroy each other's fixtures. The service queries
|
|
// unqualified table names, so a searchPath keeps it entirely in here.
|
|
const bootstrap = knex({
|
|
client: 'pg', connection: PG_URL, pool: { min: 0, max: 2 }
|
|
});
|
|
await bootstrap.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
|
await bootstrap.raw('CREATE SCHEMA usage_pg_test');
|
|
await bootstrap.destroy();
|
|
|
|
db = knex({
|
|
client: 'pg',
|
|
connection: PG_URL,
|
|
searchPath: ['usage_pg_test'],
|
|
pool: { min: 0, max: 10 }
|
|
});
|
|
// The real migrations, on the real engine.
|
|
await require('../../migrations/core/201_product_usage').up(db);
|
|
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 require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
|
|
await require('../../migrations/core/212_product_usage_prompt_shown').up(db);
|
|
|
|
await db.schema.createTable('app_settings', (t) => {
|
|
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
|
});
|
|
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');
|
|
});
|
|
await db.schema.createTable('css_templates', (t) => {
|
|
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
|
|
});
|
|
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');
|
|
});
|
|
|
|
({ UsageService } = require('../../src/usage/UsageService'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (db) {
|
|
await db.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
|
await db.destroy();
|
|
}
|
|
fs.rmSync(bindingDir, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db('product_usage_markers').delete();
|
|
await db('product_usage_state').delete();
|
|
await db('product_usage_state').insert({ id: 1 });
|
|
await db('events').delete();
|
|
await db('css_templates').delete();
|
|
await db('feature_flags').delete();
|
|
await db('app_settings').delete();
|
|
});
|
|
|
|
// The instance-binding file defaults to STORAGE_PATH, which is '/storage'
|
|
// in a bare test process. Point it at a temp dir so the real binding code
|
|
// runs rather than being stubbed out.
|
|
const bindingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-usage-pg-'));
|
|
|
|
const service = (over = {}) =>
|
|
new UsageService(db, {
|
|
secret: 'p'.repeat(48),
|
|
endpoint: 'http://127.0.0.1:9/',
|
|
bindingPath: path.join(bindingDir, 'usage-instance.key'),
|
|
fetch: async () => { throw new Error('collector unreachable in tests'); },
|
|
...over,
|
|
});
|
|
|
|
it('creates the columns with the types the code expects', async () => {
|
|
const cols = await db('product_usage_state').columnInfo();
|
|
expect(cols.cancel_seq).toBeDefined();
|
|
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
|
|
expect(cols.sequence).toBeDefined();
|
|
expect(cols.privacy_receipts).toBeDefined();
|
|
expect(cols.consent_version).toBeDefined();
|
|
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
|
|
// back as a STRING — the tick() gate compares it against a number.
|
|
expect(cols.attempts).toBeDefined();
|
|
expect(cols.next_attempt_at).toBeDefined();
|
|
expect(cols.prompt_shown).toBeDefined();
|
|
});
|
|
|
|
it('backfills the prompt for existing participation using PostgreSQL booleans', async () => {
|
|
const migration = require('../../migrations/core/212_product_usage_prompt_shown');
|
|
await migration.down(db);
|
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
|
await migration.up(db);
|
|
await migration.up(db);
|
|
expect(await service().status()).toMatchObject({ status: 'active', prompt_shown: true, consent_version: 'usage-consent.v2' });
|
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'disabled' });
|
|
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true });
|
|
});
|
|
|
|
it('persists a fresh installation declining without opting in on PostgreSQL', async () => {
|
|
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: false });
|
|
await service().markPromptShown();
|
|
expect(await service().status()).toMatchObject({ status: 'disabled', prompt_shown: true, notice_dismissed: false });
|
|
});
|
|
|
|
it('reruns the backoff migration safely', async () => {
|
|
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
|
|
await migration.up(db);
|
|
await migration.up(db);
|
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
|
expect(Number(row.attempts)).toBe(0);
|
|
expect(Number(row.next_attempt_at)).toBe(0);
|
|
});
|
|
|
|
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
|
|
let clock = 5_000_000;
|
|
let calls = 0;
|
|
const identity = generateIdentity();
|
|
const client = service({
|
|
now: () => clock,
|
|
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
|
|
});
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
status: 'active',
|
|
consent_version: 'usage-consent.v2',
|
|
installation_id: identity.installation_id,
|
|
public_key: identity.public_key,
|
|
private_key_encrypted: client.encrypt(identity.private_key),
|
|
sequence: 1,
|
|
attempts: 0,
|
|
next_attempt_at: 0,
|
|
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
|
|
});
|
|
|
|
await client.tick();
|
|
expect(calls).toBe(1);
|
|
const paced = await db('product_usage_state').where({ id: 1 }).first();
|
|
// A '5000120000' > 5000000 string comparison would be a different answer.
|
|
expect(typeof paced.next_attempt_at).toBe('string');
|
|
await client.tick();
|
|
expect(calls).toBe(1);
|
|
|
|
clock = Number(paced.next_attempt_at) + 1;
|
|
await client.tick();
|
|
expect(calls).toBe(2);
|
|
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
|
|
});
|
|
});
|
|
|
|
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
|
const migration = require('../../migrations/core/204_product_usage_privacy_receipts');
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' })
|
|
});
|
|
await migration.up(db);
|
|
await migration.up(db);
|
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
|
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();
|
|
// The thing SQLite hides: this is a string here.
|
|
expect(typeof row.cancel_seq).toBe('string');
|
|
expect(Number(row.cancel_seq)).toBe(5);
|
|
});
|
|
|
|
it('honours a withdrawal that lands while an activation is starting', async () => {
|
|
const svc = service();
|
|
const realBinding = svc.binding.bind(svc);
|
|
svc.binding = async (create = false) => {
|
|
// The withdrawal lands inside the window where the row still reads
|
|
// `disabled`, with the real binding write still happening.
|
|
if (create) await svc.disable();
|
|
return realBinding(create);
|
|
};
|
|
await svc.enable('usage-consent.v1');
|
|
|
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
|
expect(row.status).toBe('disabled');
|
|
expect(row.installation_id).toBeNull();
|
|
});
|
|
|
|
it('activates when no withdrawal arrives', async () => {
|
|
await service().enable('usage-consent.v1');
|
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
|
expect(row.status).toBe('activation_pending');
|
|
expect(row.installation_id).not.toBeNull();
|
|
});
|
|
|
|
it('records markers only while active, using SELECT ... FOR UPDATE', async () => {
|
|
const svc = service();
|
|
await svc.markUsed(['crm']);
|
|
expect(await db('product_usage_markers').count('* as c').first()).toMatchObject({ c: '0' });
|
|
|
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
|
await svc.markUsed(['crm', 'newsletters']);
|
|
const rows = await db('product_usage_markers').pluck('feature');
|
|
expect(rows.sort()).toEqual(['crm', 'newsletters']);
|
|
|
|
// onConflict().ignore() must not throw on a repeat.
|
|
await svc.markUsed(['crm']);
|
|
expect((await db('product_usage_markers').pluck('feature')).length).toBe(2);
|
|
});
|
|
|
|
it('builds a report from real booleans, not 0/1', async () => {
|
|
await db('feature_flags').insert([
|
|
{ key: 'clients', value: true },
|
|
{ key: 'newsletters', value: false },
|
|
]);
|
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
|
await service().markUsed(['crm']);
|
|
|
|
const report = await service().snapshot();
|
|
expect(report.features.crm.configured).toBe(true);
|
|
expect(report.features.crm.used).toBe(true);
|
|
expect(report.features.newsletters.configured).toBe(false);
|
|
});
|
|
|
|
it('resolves preset layouts and template CSS on this engine too', async () => {
|
|
const [tpl] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }).returning('id');
|
|
const templateId = typeof tpl === 'object' ? tpl.id : tpl;
|
|
await db('events').insert([
|
|
{ color_theme: 'modernMasonry' },
|
|
{ color_theme: null, css_template_id: templateId },
|
|
]);
|
|
await db('app_settings').insert({
|
|
setting_key: 'theme_config',
|
|
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
|
|
});
|
|
|
|
const report = await service().snapshot();
|
|
expect(report.gallery_layouts.sort()).toEqual(['carousel', 'masonry']);
|
|
expect(report.features.custom_css.configured).toBe(true);
|
|
});
|
|
});
|