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:
Paul Nothaft
2026-09-05 22:53:57 +02:00
parent 75ef137b7d
commit c7cedb00d6
3 changed files with 198 additions and 2 deletions
@@ -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);
});
});
+9 -2
View File
@@ -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;
+14
View File
@@ -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