/** * /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(); }); });