fix(usage): close the remaining withdrawal races, reset per-item name consent
Follow-up review on the previous commit, including a hole in that commit's own fix. The cancellation flag became a counter. Clearing a boolean needed a write of its own, and a /disable landing between the lease and that write was erased — the same race one level down. enable() now records the counter it started with and claims only if it is unchanged, so no clearing write exists to lose. It also fixes the case a boolean could not express at all: a stale cancellation already set, and a fresh one arriving mid-activation, are indistinguishable as flags and obvious as counts. Migration 203, separate from 202 for the reason 202 was separate from 201 — knex will not re-run an applied migration. deliver() re-checks immediately before dispatch. The existing check ran before the binding lookup, which is asynchronous, so a withdrawal that COMPLETED during it still had its registration or report sent afterwards. Not an already-in-flight request — a new one started after the operator had withdrawn. The outbox writes in tick() and command() are conditional on still being active. /disable clears pending_packet without holding the lease, so an unconditional write put a report — or a feedback body and name — back into an outbox the withdrawal had just emptied, where deliver() would then leave it, since it declines to send anything but the delete. Per-item name consent resets with the item. `named` stayed checked after submitting, so the next item carried the previous name automatically, contradicting the anonymous-by-default promise the disclosure makes for each item. The remembered name stays in preferences; attaching it is decided again each time. Two of these tests were worthless when first written and are noted because the pattern keeps recurring: the pre-dispatch case passed without the guard because an empty report payload failed schema validation during signing, so nothing reached the collector for reasons unrelated to the check. With a valid payload it fails without the guard and passes with it. Same for the counter: dropping it from the claim fails two. Refs #1110
This commit is contained in:
@@ -15,6 +15,22 @@ 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 { 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',
|
||||
@@ -38,7 +54,7 @@ async function bootDb() {
|
||||
t.text('feedback_preferences');
|
||||
t.string('lease_token', 36);
|
||||
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||
t.boolean('cancel_requested').notNullable().defaultTo(false);
|
||||
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||
});
|
||||
await db.schema.createTable('product_usage_markers', (t) => {
|
||||
t.string('feature', 60).primary();
|
||||
@@ -99,7 +115,9 @@ describe('withdrawal during an in-flight activation', () => {
|
||||
|
||||
it('does not let a stale cancellation veto a later deliberate opt-in', async () => {
|
||||
db = await bootDb();
|
||||
await db('product_usage_state').where({ id: 1 }).update({ cancel_requested: true });
|
||||
// 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');
|
||||
@@ -108,4 +126,66 @@ describe('withdrawal during an in-flight activation', () => {
|
||||
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()
|
||||
)
|
||||
),
|
||||
});
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user