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