Files
picpeak/backend/__tests__/services/usageEnableDisableRace.test.js
T
Paul Nothaft d20f80112f feat(usage): prompt existing admins once for usage reporting after an update
An admin who already had PicPeak installed before the opt-in reporting
feature existed never gets asked — the setup wizard only runs once, on
a brand-new instance. Adds a one-time modal, shown on the admin's next
dashboard visit after updating, offering the same choice the wizard
gives a new install.

- New `product_usage_state.prompt_shown` column (migration 211) and
  UsageService.markPromptShown(), set on either outcome (enable or
  decline) from both this modal and the wizard step, so an
  installation is never asked twice regardless of which path it took.
- New POST /admin/usage/prompt-seen endpoint.
- Extracted the wizard's three-point pitch (UsageReportingPitch.tsx)
  so the modal and the wizard step share identical copy instead of
  drifting apart.
- The modal never shows once participation is already active, and
  never shows a second time after either the wizard or the modal has
  been through it once.

Depends on #1360 (the setup wizard step this reuses).
2026-09-08 16:25:51 +02:00

229 lines
8.9 KiB
JavaScript

/**
* /disable overlapping an in-flight /enable (#1110).
*
* While activation generates an identity and writes its binding file the row
* still reads `disabled`, so disable()'s conditional update matched nothing
* and the lease conflict from its tick() was swallowed. The admin was told
* participation was off, and the activation then completed and left it on —
* an opt-out silently ignored, which is the one thing this feature cannot do.
*
* enable() now claims its state with a single conditional UPDATE that also
* tests the cancellation flag, so whichever lands first wins outright.
*/
const knex = require('knex');
const { UsageService } = require('../../src/usage/UsageService');
const SECRET = 'z'.repeat(48);
// A report the envelope schema accepts. An empty payload fails validation
// during signing, so the packet would never reach the collector for reasons
// unrelated to what the test is checking.
function validReport() {
const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
return {
picpeak_version: '3.0.0',
report_date: '2026-09-05',
generated_at: '2026-09-05T00:00:00.000Z',
features: Object.fromEntries(
FEATURE_KEYS.map((k) => [k, { configured: false, used: false }])
),
gallery_layouts: ['grid'],
};
}
async function bootDb() {
const db = knex({
client: 'sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
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.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64);
t.string('public_key', 59);
t.text('private_key_encrypted');
t.string('instance_binding', 64);
t.bigInteger('sequence').notNullable().defaultTo(0);
t.text('pending_packet');
t.text('last_packet');
t.text('last_receipt');
t.text('privacy_receipts');
t.string('last_report_date', 10);
t.string('last_error', 80);
t.text('feedback_preferences');
t.string('lease_token', 36);
t.bigInteger('lease_until').notNullable().defaultTo(0);
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
t.integer('attempts').notNullable().defaultTo(0);
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
});
await db.schema.createTable('product_usage_markers', (t) => {
t.string('feature', 60).primary();
});
await db('product_usage_state').insert({ id: 1 });
return db;
}
/** A service whose binding() is slow, so the race window is controllable. */
function makeService(db, { onBinding } = {}) {
const service = new UsageService(db, {
secret: SECRET,
endpoint: 'http://127.0.0.1:9/',
fetch: async () => { throw new Error('collector must not be reached'); },
});
const realBinding = service.binding.bind(service);
service.binding = async (create = false) => {
if (create && onBinding) await onBinding();
return realBinding === undefined ? 'x'.repeat(64) : 'b'.repeat(64);
};
return service;
}
describe('withdrawal during an in-flight activation', () => {
let db;
afterEach(async () => { if (db) await db.destroy(); db = null; });
it('honours a /disable that lands while /enable is still generating its identity', async () => {
db = await bootDb();
let disableDone;
const service = makeService(db, {
// Fires inside enable(), before it claims the row — exactly the window
// where the status still reads `disabled`.
onBinding: async () => { disableDone = await service.disable(); },
});
await service.enable('usage-consent.v1');
const row = await db('product_usage_state').where({ id: 1 }).first();
expect(row.status).toBe('disabled');
// Nothing was registered, so there is no identity and nothing to delete.
expect(row.installation_id).toBeNull();
expect(row.pending_packet).toBeNull();
expect(disableDone.status).toBe('disabled');
});
it('activates normally when no withdrawal arrives', async () => {
db = await bootDb();
const service = makeService(db);
await service.enable('usage-consent.v1');
const row = await db('product_usage_state').where({ id: 1 }).first();
// The collector is unreachable here, so it stops at activation_pending —
// the point is that the claim succeeded and an identity exists.
expect(row.status).toBe('activation_pending');
expect(row.installation_id).not.toBeNull();
});
it('does not let a stale cancellation veto a later deliberate opt-in', async () => {
db = await bootDb();
// A withdrawal from an earlier participation is already reflected in the
// counter when this activation reads it, so it cannot veto anything.
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 7 });
const service = makeService(db);
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('honours a withdrawal even when an earlier one was never cleared', async () => {
// The case a boolean could not express: a stale cancellation is already
// set, and a fresh one lands mid-activation. With a flag both look the
// same; with a counter the second increment is visible.
db = await bootDb();
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 3 });
const service = makeService(db, {
onBinding: async () => { await service.disable(); },
});
await service.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('does not dispatch a report when the withdrawal completes during preparation', async () => {
// deliver() checks for a withdrawal before the binding lookup, which is
// asynchronous. A /disable that COMPLETED during it used to have the
// report sent anyway — not an already-in-flight request, but a new one
// started after the operator had withdrawn.
db = await bootDb();
const posted = [];
const service = new UsageService(db, {
secret: SECRET,
endpoint: 'http://127.0.0.1:9/',
fetch: async (_url, init) => {
posted.push(JSON.parse(init.body).packet.action);
throw new Error('collector unreachable');
},
});
const identity = require('../../src/usage/protocol.cjs').generateIdentity();
await db('product_usage_state').where({ id: 1 }).update({
status: 'active',
installation_id: identity.installation_id,
public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key),
instance_binding: 'b'.repeat(64),
sequence: 1,
pending_packet: JSON.stringify(
require('../../src/usage/protocol.cjs').makePacket(
{ installation_id: identity.installation_id },
'report',
2,
validReport(),
'usage.v1'
)
),
});
// The withdrawal lands while the binding lookup is awaited.
service.binding = async () => {
await db('product_usage_state').where({ id: 1 }).update({
status: 'deletion_pending', pending_packet: null,
});
return 'b'.repeat(64);
};
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
expect(posted).not.toContain('report');
});
it('honours a withdrawal that lands between the lease claim and the state read', async () => {
// locked() claims the lease and reads the row in two statements. A
// /disable completing in that gap used to be adopted as this
// activation's own baseline and absorbed, so registration went ahead
// after the operator had withdrawn.
db = await bootDb();
const service = makeService(db);
const realState = service.state.bind(service);
let fired = false;
service.state = async () => {
// The withdrawal must land BEFORE this read returns, so the row carries
// the incremented counter. Incrementing afterwards would hand back the
// old value and both the broken and fixed versions would behave the
// same — which is exactly how an earlier version of this test passed
// against the bug it was meant to catch.
const first = await realState();
if (!fired && first.lease_token) {
fired = true;
await db('product_usage_state').where({ id: 1 }).increment('cancel_seq', 1);
return realState();
}
return first;
};
await service.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();
});
});