Files
picpeak/backend/__tests__/services/usageEnableDisableRace.test.js
T
Paul Nothaft 1e8b6f1b0f fix(usage): close the QA findings on opt-in product usage
A QA exploration of this branch against an isolated rig — own stub
collector, SQLite and PostgreSQL — turned up one dead end and a set of
signals and controls that did not hold up. This closes all of them.

Rotating JWT_SECRET, the documented response to a suspected compromise,
made the signing key unreadable. That was already named and documented,
but it left no way out: the delete packet can never be signed, so the
row stays deletion_pending forever, and enable() refuses because it is
not `disabled`. An operator who rotated precisely because the secret was
compromised cannot restore it, so the feature was bricked with no
control left. POST /usage/abandon is offered only in that state; it
drops the local identity and records the receipt as
`collector-unconfirmed` rather than claiming a deletion that did not
happen.

Every failed delivery was retried on the next admin request, and
/activity is open to any authenticated admin while the settings ticker
fires it every five minutes per open tab — 30 activity calls against a
rejecting collector produced 30 outbound requests. Migration 206 adds
attempts/next_attempt_at and the unattended sender honours the gate;
Retry and opt-out still send immediately, and the tab names the time of
the next automatic attempt.

Feedback, votes and portal sessions now share an installation-wide
budget of 30/hour. They are the only endpoints whose effect is outbound
traffic carrying operator-written free text, and the general limiter
skips authenticated requests by design. Reading status and withdrawing
stay unthrottled.

gallery_image_protection was true on a bare install with no galleries:
PicPeak ships default_protection_level='standard' and
enable_devtools_protection=true, so it reported fleet-wide 100% and
could never separate a decision from an untouched default. It now reads
only what deviates from the shipped defaults, and the devtools flag is
not read at all — being on by default, its only informative state is
off, which is the opposite of what the key claims.

Also:
- the export receipt counted every packet and called the total "usage
  reports"; reports and participant operations are now counted and named
  separately
- GET /usage/preview no longer persists the custom_css marker, so the
  transparency view stops changing what will be sent
- the feedback route requires every field the packet schema requires,
  so an API caller gets the missing field named instead of a bare
  INVALID_PACKET from inside signing
- the German strings for this feature use "Sie" throughout, matching the
  rest of the admin UI; the ignore hint says what ignoring will do
  rather than stating it as already true
- the consent dialog returns focus to the control that opened it
- the long buttons wrap instead of running off a 390px viewport
- a deletion receipt is labelled as belonging to an earlier
  participation while a new one is active

Regression tests cover each of these, including the delete packet's
reuse of the last accepted sequence, which was an unwritten assumption
about the collector rather than a defect.
2026-09-06 17:40:43 +02:00

228 lines
8.8 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.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();
});
});