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.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const { UsageService } = require('../../src/usage/UsageService');
|
||||
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
const SECRET_A = 'a'.repeat(48);
|
||||
const SECRET_B = 'b'.repeat(48);
|
||||
@@ -39,6 +40,8 @@ async function bootDb() {
|
||||
t.text('feedback_preferences');
|
||||
t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
t.integer('attempts').notNullable().defaultTo(0);
|
||||
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||
});
|
||||
await db('product_usage_state').insert({ id: 1 });
|
||||
return db;
|
||||
@@ -94,3 +97,166 @@ describe('usage signing key becomes unreadable after secret rotation', () => {
|
||||
expect(row.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Naming the failure told the operator what happened but left them nowhere to
|
||||
* go: the delete packet can never be signed, so the row stays in
|
||||
* deletion_pending forever, and enable() refuses because it is not `disabled`.
|
||||
* An operator who rotated the secret precisely because it was compromised
|
||||
* cannot restore it, so without an exit the feature is bricked.
|
||||
*/
|
||||
describe('abandoning a withdrawal that can never be signed', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
const stuck = async () => {
|
||||
db = await bootDb();
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
});
|
||||
await db('product_usage_markers').insert({ feature: 'crm' });
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'deletion_pending',
|
||||
installation_id: 'a'.repeat(64),
|
||||
public_key: 'p'.repeat(59),
|
||||
private_key_encrypted: new UsageService(db, { secret: SECRET_A }).encrypt('key'),
|
||||
sequence: 4,
|
||||
last_error: 'SIGNING_KEY_UNREADABLE',
|
||||
});
|
||||
return new UsageService(db, {
|
||||
secret: SECRET_B,
|
||||
endpoint: 'https://usage.example.test',
|
||||
bindingPath: `${require('os').tmpdir()}/usage-abandon-${Date.now()}.key`,
|
||||
fetch: () => { throw new Error('network must not be reached'); },
|
||||
});
|
||||
};
|
||||
|
||||
it('clears the local identity and records the deletion as unconfirmed', async () => {
|
||||
const service = await stuck();
|
||||
const status = await service.abandon();
|
||||
|
||||
expect(status.status).toBe('disabled');
|
||||
expect(status.installation_id).toBeNull();
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(row.private_key_encrypted).toBeNull();
|
||||
expect(row.public_key).toBeNull();
|
||||
expect(row.last_error).toBeNull();
|
||||
expect(await db('product_usage_markers').count('* as c').first()).toEqual({ c: 0 });
|
||||
|
||||
// The receipt must not claim a deletion the collector never confirmed.
|
||||
const receipt = JSON.parse(row.privacy_receipts).last_abandonment;
|
||||
expect(receipt.status).toBe('collector-unconfirmed');
|
||||
expect(receipt.reason).toBe('SIGNING_KEY_UNREADABLE');
|
||||
expect(receipt.installation_id).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('lets the operator rejoin afterwards', async () => {
|
||||
const service = await stuck();
|
||||
await service.abandon();
|
||||
expect((await service.state()).status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('refuses on a withdrawal that is merely undelivered', async () => {
|
||||
const service = await stuck();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ last_error: 'DELIVERY_FAILED' });
|
||||
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
||||
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('refuses while participation is active', async () => {
|
||||
const service = await stuck();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
||||
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every failed delivery used to be 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. A permanently rejected packet therefore
|
||||
* produced one collector request per admin action, indefinitely.
|
||||
*/
|
||||
describe('delivery backoff', () => {
|
||||
let db;
|
||||
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||
|
||||
// A real identity and a schema-valid packet, so the failure happens where
|
||||
// this test claims it does — at the network — rather than in signPacket.
|
||||
const activeWithPendingPacket = async (fetchImpl, now) => {
|
||||
db = await bootDb();
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
});
|
||||
const service = new UsageService(db, {
|
||||
secret: SECRET_A,
|
||||
endpoint: 'https://usage.example.test',
|
||||
now: () => now(),
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const identity = generateIdentity();
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'active',
|
||||
consent_version: 'usage-consent.v2',
|
||||
installation_id: identity.installation_id,
|
||||
public_key: identity.public_key,
|
||||
private_key_encrypted: service.encrypt(identity.private_key),
|
||||
sequence: 1,
|
||||
pending_packet: JSON.stringify(
|
||||
makePacket(identity, 'session', 2, {}, 'usage.v2')
|
||||
),
|
||||
});
|
||||
return service;
|
||||
};
|
||||
|
||||
it('paces the next unattended attempt after a failure, and lets Retry skip it', async () => {
|
||||
let clock = 1_000_000;
|
||||
let calls = 0;
|
||||
const service = await activeWithPendingPacket(() => {
|
||||
calls += 1;
|
||||
throw new Error('collector unreachable');
|
||||
}, () => clock);
|
||||
|
||||
await service.tick();
|
||||
expect(calls).toBe(1);
|
||||
const paced = await service.state();
|
||||
expect(Number(paced.attempts)).toBe(1);
|
||||
expect(Number(paced.next_attempt_at)).toBeGreaterThan(clock);
|
||||
|
||||
// The unattended callers — /activity and the settings ticker — wait.
|
||||
await service.tick();
|
||||
await service.tick();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
// The operator pressing Retry does not.
|
||||
await service.tick({ force: true });
|
||||
expect(calls).toBe(2);
|
||||
expect(Number((await service.state()).attempts)).toBe(2);
|
||||
|
||||
// Once the window passes, the automatic sender tries again on its own.
|
||||
clock = Number((await service.state()).next_attempt_at) + 1;
|
||||
await service.tick();
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it('grows the wait with consecutive failures and caps it at an hour', () => {
|
||||
const service = new UsageService(null, { secret: SECRET_A, endpoint: 'https://usage.example.test' });
|
||||
expect(service.backoffMs(1)).toBe(2 * 60000);
|
||||
expect(service.backoffMs(3)).toBe(8 * 60000);
|
||||
expect(service.backoffMs(20)).toBe(60 * 60000);
|
||||
});
|
||||
|
||||
it('clears the pacing once a packet is accepted', async () => {
|
||||
const clock = 1_000_000;
|
||||
const service = await activeWithPendingPacket(async () => {
|
||||
throw new Error('collector unreachable');
|
||||
}, () => clock);
|
||||
await service.tick();
|
||||
expect(Number((await service.state()).attempts)).toBe(1);
|
||||
|
||||
await service.clearDeliveryBackoff();
|
||||
const cleared = await service.state();
|
||||
expect(Number(cleared.attempts)).toBe(0);
|
||||
expect(Number(cleared.next_attempt_at)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user