test(usage): prove product usage works on PostgreSQL, and harden the collector default
Everything about this feature had been exercised on SQLite only, which is the engine least likely to show its problems. Adds __tests__/integration/productUsagePg.test.js, following the gated pattern the .picpeak restore suites use: it runs the real migrations 201-203 against a real PostgreSQL and covers what SQLite cannot answer. node-postgres returns bigint as a STRING, and the withdrawal guard compares `cancel_seq` — a `'1' !== 1` slip there would let an activation complete after an opt-out, and SQLite, which hands back a number, would never show it. Booleans are real booleans rather than 0/1, which is what every `configured` signal in a report is built from. And markUsed takes SELECT ... FOR UPDATE on this engine only. Seven cases, all passing against PostgreSQL 15. Removing the compare-and-swap condition fails the withdrawal case there too, so the suite has teeth on that engine and not only on SQLite. CI already provides PICPEAK_PG_TEST_URL, so these run there rather than skipping. The collector default is harder to lose. An unset, empty or whitespace-only USAGE_COLLECTOR_URL now falls back to https://usage.picpeak.app — deployments that template the variable in (docker-compose writes ${USAGE_COLLECTOR_URL:-...}) can hand over an empty string, and that has to mean "use the default" rather than "no collector". A value that is present but malformed is still reported as a configuration error instead of being silently replaced: quietly retargeting a self-hoster's collector at ours would send their reports somewhere they did not choose. Refs #1110
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* 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:[email protected]: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 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 () => {
|
||||||
|
db = knex({ client: 'pg', connection: PG_URL, pool: { min: 0, max: 10 } });
|
||||||
|
for (const t of ['product_usage_markers', 'product_usage_state', 'app_settings',
|
||||||
|
'feature_flags', 'events', 'css_templates', 'email_configs',
|
||||||
|
'mail_accounts', 'whatsapp_configs']) {
|
||||||
|
await db.raw(`DROP TABLE IF EXISTS ${t} CASCADE`);
|
||||||
|
}
|
||||||
|
// 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 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.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,8 +109,15 @@ class UsageService {
|
|||||||
options.secret ||
|
options.secret ||
|
||||||
process.env.USAGE_ENCRYPTION_KEY ||
|
process.env.USAGE_ENCRYPTION_KEY ||
|
||||||
process.env.JWT_SECRET;
|
process.env.JWT_SECRET;
|
||||||
this.endpoint =
|
// Falls back to the default whenever nothing usable is configured —
|
||||||
options.endpoint || process.env.USAGE_COLLECTOR_URL || DEFAULT_COLLECTOR_URL;
|
// unset, empty, or whitespace. Deployments that template the variable in
|
||||||
|
// (docker-compose writes USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-...})
|
||||||
|
// can hand over an empty string, and that must mean "use the default",
|
||||||
|
// not "no collector". A value that is present but malformed is NOT
|
||||||
|
// silently replaced: quietly retargeting a self-hoster's collector at
|
||||||
|
// ours would send their reports somewhere they did not choose.
|
||||||
|
const configured = (options.endpoint ?? process.env.USAGE_COLLECTOR_URL ?? '').trim();
|
||||||
|
this.endpoint = configured || DEFAULT_COLLECTOR_URL;
|
||||||
this.bindingPath =
|
this.bindingPath =
|
||||||
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
|
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
|
||||||
this.encKey = null;
|
this.encKey = null;
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ tracker or CORS policy is required. The collector is the separate
|
|||||||
| `USAGE_COLLECTOR_URL` | https://usage.picpeak.app | Fixed operator-configured collector origin, HTTPS in production |
|
| `USAGE_COLLECTOR_URL` | https://usage.picpeak.app | Fixed operator-configured collector origin, HTTPS in production |
|
||||||
| `USAGE_ENCRYPTION_KEY` | JWT_SECRET | 32+ characters, encrypts the local Ed25519 key with AES-256-GCM |
|
| `USAGE_ENCRYPTION_KEY` | JWT_SECRET | 32+ characters, encrypts the local Ed25519 key with AES-256-GCM |
|
||||||
|
|
||||||
|
Both database engines are supported. The state and marker tables are created
|
||||||
|
by migrations 201-203 on PostgreSQL and SQLite alike, and the engine-sensitive
|
||||||
|
paths are covered by `__tests__/integration/productUsagePg.test.js` against a
|
||||||
|
real PostgreSQL — bigint columns come back as strings there, booleans are real
|
||||||
|
booleans rather than 0/1, and the marker write takes `SELECT ... FOR UPDATE`
|
||||||
|
only on that engine. That suite is gated behind `PICPEAK_PG_TEST_URL` and runs
|
||||||
|
in CI, which provides one.
|
||||||
|
|
||||||
|
If `USAGE_COLLECTOR_URL` is unset, empty or blank the built-in default
|
||||||
|
`https://usage.picpeak.app` is used. A value that is present but malformed is
|
||||||
|
reported as a configuration error rather than being replaced by the default:
|
||||||
|
silently retargeting a self-hosted collector at ours would send reports
|
||||||
|
somewhere the operator did not choose.
|
||||||
|
|
||||||
Local development can use an HTTP loopback collector outside production. The
|
Local development can use an HTTP loopback collector outside production. The
|
||||||
collector URL is never writable through generic settings or request payloads.
|
collector URL is never writable through generic settings or request payloads.
|
||||||
Keep the encryption material stable and protected; losing it makes the old
|
Keep the encryption material stable and protected; losing it makes the old
|
||||||
|
|||||||
Reference in New Issue
Block a user