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:
@@ -18,6 +18,7 @@ const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
@@ -52,6 +53,7 @@ maybe('product usage on Postgres', () => {
|
||||
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
|
||||
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
|
||||
await require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
|
||||
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||
@@ -114,6 +116,56 @@ maybe('product usage on Postgres', () => {
|
||||
expect(cols.sequence).toBeDefined();
|
||||
expect(cols.privacy_receipts).toBeDefined();
|
||||
expect(cols.consent_version).toBeDefined();
|
||||
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
|
||||
// back as a STRING — the tick() gate compares it against a number.
|
||||
expect(cols.attempts).toBeDefined();
|
||||
expect(cols.next_attempt_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('reruns the backoff migration safely', async () => {
|
||||
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
|
||||
await migration.up(db);
|
||||
await migration.up(db);
|
||||
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||
expect(Number(row.attempts)).toBe(0);
|
||||
expect(Number(row.next_attempt_at)).toBe(0);
|
||||
});
|
||||
|
||||
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
|
||||
let clock = 5_000_000;
|
||||
let calls = 0;
|
||||
const identity = generateIdentity();
|
||||
const client = service({
|
||||
now: () => clock,
|
||||
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
|
||||
});
|
||||
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: client.encrypt(identity.private_key),
|
||||
sequence: 1,
|
||||
attempts: 0,
|
||||
next_attempt_at: 0,
|
||||
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
|
||||
});
|
||||
|
||||
await client.tick();
|
||||
expect(calls).toBe(1);
|
||||
const paced = await db('product_usage_state').where({ id: 1 }).first();
|
||||
// A '5000120000' > 5000000 string comparison would be a different answer.
|
||||
expect(typeof paced.next_attempt_at).toBe('string');
|
||||
await client.tick();
|
||||
expect(calls).toBe(1);
|
||||
|
||||
clock = Number(paced.next_attempt_at) + 1;
|
||||
await client.tick();
|
||||
expect(calls).toBe(2);
|
||||
|
||||
await db('product_usage_state').where({ id: 1 }).update({
|
||||
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
||||
|
||||
Reference in New Issue
Block a user