diff --git a/backend/__tests__/integration/productUsagePg.test.js b/backend/__tests__/integration/productUsagePg.test.js index aed5fb06..08306b90 100644 --- a/backend/__tests__/integration/productUsagePg.test.js +++ b/backend/__tests__/integration/productUsagePg.test.js @@ -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 () => { diff --git a/backend/__tests__/routes/adminUsage.test.js b/backend/__tests__/routes/adminUsage.test.js index 9ea9a8e4..6d515052 100644 --- a/backend/__tests__/routes/adminUsage.test.js +++ b/backend/__tests__/routes/adminUsage.test.js @@ -31,6 +31,7 @@ jest.mock('../../src/services/productUsageService', () => 'dismiss', 'enable', 'disable', + 'abandon', 'preview', 'export', 'preferences', @@ -125,6 +126,7 @@ const ROUTES = [ ['post', '/enable'], ['post', '/consent'], ['post', '/disable'], + ['post', '/abandon'], ['post', '/retry'], ['post', '/dismiss'], ['get', '/preview'], @@ -230,3 +232,89 @@ test('only a backup that writes to the configured destination flags S3', () => { ['/backup/picpeak/export', false], ]); }); + +// The route allowlist and the packet schema have to agree. The allowlist used +// to let `name`, `allow_public` and `allow_marketing` be omitted while the +// schema requires all three, so an API caller got a bare INVALID_PACKET from +// deep inside signing instead of being told which field was missing. +const VALID_FEEDBACK = { + kind: 'feedback', + title: 'Title', + body: 'Body', + name: '', + allow_public: false, + allow_marketing: false +}; +test.each([ + ['no body at all', {}], + ['missing name', { ...VALID_FEEDBACK, name: undefined }], + ['missing allow_public', { ...VALID_FEEDBACK, allow_public: undefined }], + ['missing allow_marketing', { ...VALID_FEEDBACK, allow_marketing: undefined }], + ['a boolean sent as a string', { ...VALID_FEEDBACK, allow_public: 'true' }], + ['a title of only whitespace', { ...VALID_FEEDBACK, title: ' ' }], + ['an unknown field', { ...VALID_FEEDBACK, ownerId: 7 }] +])('feedback rejects %s before anything is signed', async (_label, data) => { + const response = await request(app) + .post('/api/admin/usage/feedback') + .set('Authorization', `Bearer ${token('admin')}`) + .send(JSON.parse(JSON.stringify(data))) + .expect(400); + // Named, not a bare protocol failure the caller cannot act on. + expect(response.body.code).toBe('VALIDATION_ERROR'); + expect(service.command).not.toHaveBeenCalled(); +}); +test('feedback accepts the complete payload and mints the id server-side', async () => { + await request(app) + .post('/api/admin/usage/feedback') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ ...VALID_FEEDBACK, name: 'QA' }) + .expect(200); + expect(service.command).toHaveBeenCalledWith( + 'feedback', + expect.objectContaining({ name: 'QA', feedback_id: expect.any(String) }) + ); +}); + +// Runs last on purpose: the limiter's budget is per-process and shared with +// every test above that reaches an outbound route, so consuming it here cannot +// starve them. The assertion is deliberately about the property — some request +// is refused and the service stops being called — rather than an exact count, +// which would depend on how much budget earlier tests used. +test('the outbound routes are throttled so an admin session cannot flood the collector', async () => { + const codes = []; + for (let i = 0; i < 45; i += 1) { + const response = await request(app) + .post('/api/admin/usage/feedback') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ ...VALID_FEEDBACK, title: `flood ${i}` }); + codes.push(response.status); + if (response.status === 429) { + expect(response.body.code).toBe('USAGE_RATE_LIMITED'); + break; + } + } + expect(codes).toContain(429); + expect(service.command.mock.calls.length).toBeLessThan(codes.length); + + // The same budget covers the other two routes that relay to the collector. + await request(app) + .post('/api/admin/usage/vote') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ feedback_id: '11111111-1111-4111-8111-111111111111', voted: true }) + .expect(429); + await request(app) + .post('/api/admin/usage/portal-session') + .set('Authorization', `Bearer ${token('admin')}`) + .expect(429); + + // Reading status and withdrawing must never be throttled: those are how an + // operator sees what is happening and how they get out. + await request(app) + .get('/api/admin/usage') + .set('Authorization', `Bearer ${token('admin')}`) + .expect(200); + await request(app) + .post('/api/admin/usage/disable') + .set('Authorization', `Bearer ${token('admin')}`) + .expect(200); +}); diff --git a/backend/__tests__/services/usageEnableDisableRace.test.js b/backend/__tests__/services/usageEnableDisableRace.test.js index 4612dc2c..b7d24ff8 100644 --- a/backend/__tests__/services/usageEnableDisableRace.test.js +++ b/backend/__tests__/services/usageEnableDisableRace.test.js @@ -57,6 +57,8 @@ async function bootDb() { 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(); diff --git a/backend/__tests__/services/usageExportReceipt.test.js b/backend/__tests__/services/usageExportReceipt.test.js new file mode 100644 index 00000000..1dd4b5f0 --- /dev/null +++ b/backend/__tests__/services/usageExportReceipt.test.js @@ -0,0 +1,147 @@ +/** + * Two things the participant is entitled to have stated exactly. + * + * The export receipt is a privacy document — the artefact an operator shows a + * third party — so a count in it has to mean what its label says. It counted + * every packet in the participation (feedback, votes, portal sessions, the + * registration) and called the total "usage reports": an install that had sent + * one report and twenty feedback items reported twenty-one reports. + * + * The delete packet's sequence is the other: it reuses the last ACCEPTED + * sequence rather than taking the next one, unlike every other action. That is + * a contract with the collector, not an implementation detail — if the + * collector ever enforced strictly increasing sequences per installation, the + * withdrawal would be rejected forever and the operator could never leave. It + * is pinned here so the assumption is written down and a change to it has to + * be deliberate. + */ +const knex = require('knex'); +const { UsageService } = require('../../src/usage/UsageService'); +const { generateIdentity, verifyEnvelope } = require('../../src/usage/protocol.cjs'); + +const SECRET = 's'.repeat(48); + +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.v2'); + 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('product_usage_state').insert({ id: 1 }); + await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary()); + return db; +} + +const envelope = (action) => ({ packet: { action, installation_id: 'a'.repeat(64) } }); + +describe('the export receipt states what it actually counted', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const exportWith = async (packets) => { + db = await bootDb(); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + installation_id: 'a'.repeat(64), + }); + const service = new UsageService(db, { + secret: SECRET, + endpoint: 'https://usage.example.test', + now: () => Date.parse('2026-09-06T12:00:00.000Z'), + fetch: async () => ({ + ok: true, + headers: { get: () => null }, + body: (async function* () { + yield Buffer.from(JSON.stringify({ installation_id: 'a'.repeat(64), packets })); + })(), + }), + }); + await service.export(); + return JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).privacy_receipts) + .last_export; + }; + + it('counts reports as reports and everything else separately', async () => { + const receipt = await exportWith([ + envelope('register'), + envelope('report'), + envelope('consent'), + envelope('feedback'), + envelope('feedback'), + envelope('vote'), + envelope('session'), + ]); + expect(receipt.report_count).toBe(1); + expect(receipt.packet_count).toBe(7); + expect(receipt.scope).toEqual([ + 'accepted usage reports', + 'accepted participant operations', + ]); + }); + + it('reports zero rather than a total when no report was ever accepted', async () => { + const receipt = await exportWith([envelope('register'), envelope('feedback')]); + expect(receipt.report_count).toBe(0); + expect(receipt.packet_count).toBe(2); + }); +}); + +describe('the delete packet reuses the last accepted sequence', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('sends the accepted sequence, not the next one', async () => { + db = await bootDb(); + const identity = generateIdentity(); + const sent = []; + const service = new UsageService(db, { + secret: SECRET, + endpoint: 'https://usage.example.test', + now: () => Date.parse('2026-09-06T12:00:00.000Z'), + bindingPath: `${require('os').tmpdir()}/usage-delete-seq-${Date.now()}.key`, + fetch: async (_url, options) => { + const body = JSON.parse(options.body); + sent.push(verifyEnvelope(body, Date.parse('2026-09-06T12:00:00.000Z'))); + // Deliberately not a sequence-enforcing collector: this test pins what + // PicPeak sends, and the collector contract is what must match it. + throw new Error('stop after capturing the packet'); + }, + }); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'deletion_pending', + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: service.encrypt(identity.private_key), + sequence: 7, + }); + + await service.tick({ force: true }); + + expect(sent).toHaveLength(1); + expect(sent[0].action).toBe('delete'); + expect(sent[0].sequence).toBe(7); + }); +}); diff --git a/backend/__tests__/services/usageServiceKeyRotation.test.js b/backend/__tests__/services/usageServiceKeyRotation.test.js index 938ef6e9..53116fc9 100644 --- a/backend/__tests__/services/usageServiceKeyRotation.test.js +++ b/backend/__tests__/services/usageServiceKeyRotation.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/services/usageSnapshotSignals.test.js b/backend/__tests__/services/usageSnapshotSignals.test.js index 5b395466..c12b5407 100644 --- a/backend/__tests__/services/usageSnapshotSignals.test.js +++ b/backend/__tests__/services/usageSnapshotSignals.test.js @@ -34,6 +34,8 @@ async function bootDb() { 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('product_usage_state').insert({ id: 1 }); await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary()); @@ -196,6 +198,116 @@ describe('S3 use is only implied by backups that write to the destination', () = }); }); +/** + * A signal whose answer is fixed by the shipped defaults is not a signal. + * PicPeak ships default_protection_level='standard' and + * enable_devtools_protection=true, so accepting either as evidence made + * gallery_image_protection true on a bare install with no galleries — a + * fleet-wide 100% that cannot separate a decision from an untouched default. + */ +describe('gallery_image_protection reports decisions, not shipped defaults', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const v2 = async () => { + db = await bootDb(); + await db.schema.alterTable('events', (t) => { + for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column); + t.string('protection_level'); + }); + await db('product_usage_state').where({ id: 1 }) + .update({ status: 'active', consent_version: 'usage-consent.v2' }); + return service(db); + }; + const shipped = async () => { + // Exactly what migration 038 seeds, plus an event carrying the column + // defaults from the same migration. + await db('app_settings').insert([ + { setting_key: 'default_protection_level', setting_value: '"standard"' }, + { setting_key: 'enable_devtools_protection', setting_value: 'true' }, + { setting_key: 'enable_canvas_rendering', setting_value: 'false' }, + ]); + await db('events').insert({ + protection_level: 'standard', + enable_devtools_protection: true, + use_canvas_rendering: false, + disable_right_click: false, + }); + }; + + it('is false on a bare install with no galleries at all', async () => { + const client = await v2(); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: false }); + }); + + it('is false when every value is still the shipped default', async () => { + const client = await v2(); + await shipped(); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: false }); + }); + + it('ignores the devtools flag entirely, since it ships on', async () => { + const client = await v2(); + await shipped(); + // Turning it OFF is the only informative state it has, and that is the + // opposite of what this key claims — so neither state may set it. + await db('app_settings').where({ setting_key: 'enable_devtools_protection' }) + .update({ setting_value: 'false' }); + await db('events').update({ enable_devtools_protection: false }); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: false }); + }); + + it.each([ + ['a stronger global level', async (db) => db('app_settings').where({ setting_key: 'default_protection_level' }).update({ setting_value: '"maximum"' })], + ['global canvas rendering', async (db) => db('app_settings').where({ setting_key: 'enable_canvas_rendering' }).update({ setting_value: 'true' })], + ['a stronger level on one gallery', async (db) => db('events').update({ protection_level: 'enhanced' })], + ['canvas rendering on one gallery', async (db) => db('events').update({ use_canvas_rendering: true })], + ['right-click disabled on one gallery', async (db) => db('events').update({ disable_right_click: true })], + ])('is true for %s', async (_label, change) => { + const client = await v2(); + await shipped(); + await change(db); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: true }); + }); +}); + +/** + * The settings preview is the "see exactly what would be sent" view. It shared + * snapshot() with the real sender, and snapshot() records applied custom CSS + * as a lifetime marker — so reading the transparency view wrote a marker. + */ +describe('preview does not change what will be sent', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const withAppliedCss = async () => { + db = await bootDb(); + await db('product_usage_state').where({ id: 1 }) + .update({ status: 'active', consent_version: 'usage-consent.v2' }); + await db('app_settings').insert({ + setting_key: 'general_custom_css', setting_value: '".x{}"' + }); + return service(db); + }; + + it('reports custom_css as used without persisting the marker', async () => { + const client = await withAppliedCss(); + const preview = await client.preview(); + expect(preview.features.custom_css).toEqual({ configured: true, used: true }); + expect(await db('product_usage_markers').pluck('feature')).toEqual([]); + }); + + it('still persists it when the sender builds the real report', async () => { + const client = await withAppliedCss(); + await client.snapshot(); + expect(await db('product_usage_markers').pluck('feature')).toEqual(['custom_css']); + }); +}); + describe('v2 technical configuration and privacy boundaries', () => { let db; let savedEnv; diff --git a/backend/migrations/core/206_product_usage_delivery_backoff.js b/backend/migrations/core/206_product_usage_delivery_backoff.js new file mode 100644 index 00000000..19118a41 --- /dev/null +++ b/backend/migrations/core/206_product_usage_delivery_backoff.js @@ -0,0 +1,31 @@ +// Retry pacing for the collector. Without it every failed packet was retried +// on the next admin request: /activity is open to any authenticated admin and +// the settings ticker fires it every five minutes per open tab, so an +// installation whose packet the collector rejects permanently hammered it +// once per admin action, forever, with a failing request sitting on the +// critical path of that action. +// +// `attempts` counts consecutive failures and `next_attempt_at` is the epoch-ms +// gate the automatic sender honours. Explicit operator actions — Retry and +// Disable — pass through regardless; the point is to pace the unattended loop, +// not to make the admin wait out a backoff they asked to skip. +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + if (!(await knex.schema.hasColumn('product_usage_state', 'attempts'))) + await knex.schema.alterTable('product_usage_state', (t) => { + t.integer('attempts').notNullable().defaultTo(0); + }); + if (!(await knex.schema.hasColumn('product_usage_state', 'next_attempt_at'))) + await knex.schema.alterTable('product_usage_state', (t) => { + t.bigInteger('next_attempt_at').notNullable().defaultTo(0); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + for (const column of ['attempts', 'next_attempt_at']) + if (await knex.schema.hasColumn('product_usage_state', column)) + await knex.schema.alterTable('product_usage_state', (t) => { + t.dropColumn(column); + }); +}; diff --git a/backend/src/routes/adminUsage.js b/backend/src/routes/adminUsage.js index 44af6c5d..e14f15bf 100644 --- a/backend/src/routes/adminUsage.js +++ b/backend/src/routes/adminUsage.js @@ -1,5 +1,6 @@ const express = require('express'); const crypto = require('crypto'); +const rateLimit = require('express-rate-limit'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { ValidationError } = require('../utils/errors'); @@ -21,6 +22,30 @@ const wrap = (fn) => (req, res, next) => .json({ error: 'Invalid usage request', code: error.code }); next(error); }); +// The three routes below are the only ones whose effect is an outbound +// request to someone else's service, carrying operator-written free text +// (title 120, body 4000, name 80). The platform's general limiter skips +// authenticated requests by design, which is right for endpoints that only +// touch this installation and wrong for a relay: without this an admin +// session can push unbounded traffic at the collector. +// +// Keyed to the installation, not the caller's IP, because the budget being +// protected is "how much this install relays", and per-process because that +// is the same store the rest of the app uses — a multi-replica deployment +// gets one budget per replica, which still bounds the shape that matters. +const outboundLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 30, + keyGenerator: () => 'usage-outbound', + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => + res.status(429).json({ + error: 'Too many usage submissions. Try again later.', + code: 'USAGE_RATE_LIMITED' + }) +}); + router.use(adminAuth); router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); @@ -66,9 +91,17 @@ router.post( '/disable', wrap(async (_req, res) => res.json(await service.disable())) ); +// Reachable only from a withdrawal whose delete packet can never be signed; +// the service refuses in every other state. See UsageService.abandon(). +router.post( + '/abandon', + wrap(async (_req, res) => res.json(await service.abandon())) +); +// An operator asking for a retry skips the delivery backoff — that button +// exists precisely to not wait for the next window. router.post( '/retry', - wrap(async (_req, res) => res.json(await service.tick())) + wrap(async (_req, res) => res.json(await service.tick({ force: true }))) ); router.get( '/preview', @@ -84,29 +117,41 @@ router.put( '/feedback-preferences', wrap(async (req, res) => res.json(await service.preferences(req.body))) ); +// Every field the packet schema requires. The allowlist used to let `name`, +// `allow_public` and `allow_marketing` be omitted, and the packet schema — +// which requires all of them — then failed with a bare INVALID_PACKET instead +// of naming the missing field. The UI always sends them; anything driving the +// API directly did not, and got an error it could not act on. +const FEEDBACK_FIELDS = [ + 'kind', + 'title', + 'body', + 'name', + 'allow_public', + 'allow_marketing' +]; router.post( '/feedback', + outboundLimiter, wrap(async (req, res) => { const body = req.body; - if ( - !body || - Object.keys(body).some( - (k) => - ![ - 'kind', - 'title', - 'body', - 'name', - 'allow_public', - 'allow_marketing' - ].includes(k) - ) || - typeof body.title !== 'string' || - !body.title.trim() || - typeof body.body !== 'string' || - !body.body.trim() - ) + if (!body || typeof body !== 'object') throw new ValidationError('Invalid feedback'); + const unknown = Object.keys(body).filter( + (key) => !FEEDBACK_FIELDS.includes(key) + ); + if (unknown.length) + throw new ValidationError( + `Unknown feedback fields: ${unknown.join(', ')}` + ); + for (const key of ['kind', 'title', 'body', 'name']) + if (typeof body[key] !== 'string') + throw new ValidationError(`Feedback field "${key}" must be a string`); + for (const key of ['allow_public', 'allow_marketing']) + if (typeof body[key] !== 'boolean') + throw new ValidationError(`Feedback field "${key}" must be a boolean`); + if (!body.title.trim() || !body.body.trim()) + throw new ValidationError('Feedback title and body are required'); res.json( await service.command('feedback', { ...body, @@ -117,10 +162,12 @@ router.post( ); router.post( '/vote', + outboundLimiter, wrap(async (req, res) => res.json(await service.command('vote', req.body))) ); router.post( '/portal-session', + outboundLimiter, wrap(async (_req, res) => { const result = await service.command('session', {}); res.json({ diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js index 9b5618da..ed4ba145 100644 --- a/backend/src/usage/UsageService.js +++ b/backend/src/usage/UsageService.js @@ -248,6 +248,20 @@ class UsageService { consent_update_available: state.status === 'active' && this.schemaVersion(state) !== CURRENT_SCHEMA_VERSION, last_report_date: state.last_report_date, last_error: state.last_error, + // Epoch ms, or null when nothing is being paced. The settings page shows + // it so a waiting install reads as "waiting" rather than as broken. + retry_after: + Number(state.next_attempt_at || 0) > this.now() + ? Number(state.next_attempt_at) + : null, + // The one failure the operator cannot retry their way out of: the + // signing key is unreadable, so the delete packet can never be signed. + // Without this flag the settings page has no way to offer the only + // remaining exit (abandon), and the install sits in deletion_pending + // forever. + can_abandon: + state.status === 'deletion_pending' && + state.last_error === 'SIGNING_KEY_UNREADABLE', pending_action: state.pending_packet ? JSON.parse(state.pending_packet).action : null, @@ -278,6 +292,29 @@ class UsageService { } } + // Consecutive failures pace the unattended sender: 2, 4, 8, 16, 32 minutes, + // then hourly. Capped rather than unbounded because a collector that comes + // back after a long outage should be noticed within the hour, and a report + // is only due once per UTC day anyway. + backoffMs(attempts) { + return Math.min(2 ** Math.max(1, attempts), 60) * 60000; + } + async noteDeliveryFailure() { + const state = await this.state(); + const attempts = Number(state?.attempts || 0) + 1; + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ + attempts, + next_attempt_at: this.now() + this.backoffMs(attempts) + }); + } + async clearDeliveryBackoff() { + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ attempts: 0, next_attempt_at: 0 }); + } + async dismiss() { await this.db('product_usage_state') .where({ id: 1 }) @@ -329,7 +366,9 @@ class UsageService { instance_binding: instanceBinding, sequence: 0, pending_packet: JSON.stringify(pending), - last_error: null + last_error: null, + attempts: 0, + next_attempt_at: 0 }); // Withdrawn while activating. Participation stays off and nothing was // registered, so there is nothing to delete remotely either. @@ -363,11 +402,13 @@ class UsageService { pending_packet: null, last_packet: null, last_receipt: null, - last_report_date: null + last_report_date: null, + attempts: 0, + next_attempt_at: 0 }); await this.db('product_usage_markers').delete(); try { - await this.tick(); + await this.tick({ force: true }); } catch (error) { // A sender may still own the lease. Collection is already stopped and // the next admin activity retries deletion after that sender finishes. @@ -376,6 +417,71 @@ class UsageService { return this.status(); } + // The escape hatch for a withdrawal that can never be signed. When + // USAGE_ENCRYPTION_KEY — or the JWT_SECRET it falls back to — has been + // rotated, the private key is unreadable, so the delete packet cannot be + // produced at all. Retrying and disabling both no-op forever, and enable() + // refuses because the row is not `disabled`: the feature is bricked with no + // control left. Restoring the old key material is the correct fix and stays + // the documented one, but an operator who rotated because of a suspected + // compromise no longer has it. + // + // This drops the local identity and says so honestly: collection is already + // stopped, but the collector was never told, so the receipt records + // `collector-unconfirmed` rather than claiming a deletion that did not + // happen. Deliberately not folded into enable() — abandoning an + // unconfirmed deletion is its own decision, not a side effect of opting in. + async abandon() { + await this.locked(async (state) => { + if ( + state.status !== 'deletion_pending' || + state.last_error !== 'SIGNING_KEY_UNREADABLE' + ) + throw new ConflictError( + 'Only an unsignable withdrawal can be abandoned' + ); + await fs.unlink(this.bindingPath).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await this.db('product_usage_markers').delete(); + const receipts = state.privacy_receipts + ? JSON.parse(state.privacy_receipts) + : {}; + await this.db('product_usage_state') + .where({ id: 1, status: 'deletion_pending' }) + .update({ + status: 'disabled', + installation_id: null, + public_key: null, + private_key_encrypted: null, + instance_binding: null, + pending_packet: null, + last_packet: null, + last_receipt: null, + last_report_date: null, + last_error: null, + sequence: 0, + attempts: 0, + next_attempt_at: 0, + feedback_preferences: null, + privacy_receipts: JSON.stringify({ + ...receipts, + last_abandonment: { + receipt_version: 'local-audit.v1', + kind: 'abandonment', + receipt_id: crypto.randomUUID(), + confirmed_at: new Date(this.now()).toISOString(), + status: 'collector-unconfirmed', + reason: 'SIGNING_KEY_UNREADABLE', + installation_id: state.installation_id, + scope: ['local identity', 'local markers', 'local key material'] + } + }) + }); + }); + return this.status(); + } + async post(pathname, body, maxResponseBytes = 65536) { const response = await this.fetch(`${this.collectorUrl()}${pathname}`, { method: 'POST', @@ -496,6 +602,8 @@ class UsageService { last_report_date: null, last_error: null, sequence: 0, + attempts: 0, + next_attempt_at: 0, feedback_preferences: null }); } else { @@ -505,6 +613,8 @@ class UsageService { sequence: packet.sequence, pending_packet: null, last_error: null, + attempts: 0, + next_attempt_at: 0, last_receipt: JSON.stringify(storedReceipt) }; if (packet.action === 'report') { @@ -556,7 +666,12 @@ class UsageService { await this.db('product_usage_state') .where({ id: 1 }) .whereNot({ status: 'deletion_pending' }) - .update({ pending_packet: null, last_error: 'REQUEST_REJECTED' }); + .update({ + pending_packet: null, + last_error: 'REQUEST_REJECTED', + attempts: 0, + next_attempt_at: 0 + }); return null; } const conflict = [ @@ -574,6 +689,11 @@ class UsageService { await this.db('product_usage_state') .where({ id: 1 }) .update({ last_error: code }); + // Paced, not abandoned: the packet stays pending and the operator can + // still force a retry from the settings page. Only the automatic sender + // waits, which is what stops one permanently rejected packet from + // producing one collector request per admin click. + await this.noteDeliveryFailure(); if (conflict && packet.action !== 'delete') { await this.db('product_usage_state') .where({ id: 1 }) @@ -584,9 +704,15 @@ class UsageService { } } - async tick() { + // `force` is what the Retry button and /disable pass: an operator asking for + // an attempt now must not be held behind a backoff they can see and want to + // skip. The unattended callers — /activity and the settings ticker — leave + // it off, so a failing packet costs one request per backoff window instead + // of one per admin action. + async tick({ force = false } = {}) { await this.locked(async (state) => { if (state.status === 'disabled') return; + if (!force && Number(state.next_attempt_at || 0) > this.now()) return; if (state.status === 'deletion_pending') { const packet = makePacket(state, 'delete', Number(state.sequence), {}, this.schemaVersion(state)); state.pending_packet = JSON.stringify(packet); @@ -666,7 +792,13 @@ class UsageService { }); } - async snapshot(version) { + // `persist` is false for the settings preview. snapshot() records applied + // custom CSS as a lifetime marker, which meant the "see exactly what would + // be sent" view changed what gets sent — a read with a write behind it, in + // the one place whose whole job is transparency. The reported value is + // unaffected: the marker is derived here either way, and the next real + // report persists it. + async snapshot(version, { persist = true } = {}) { version = version || this.schemaVersion(await this.state()); const rows = await this.db('app_settings') .whereIn('setting_key', SETTING_KEYS) @@ -777,7 +909,7 @@ class UsageService { // Applied CSS is already a capability in use; no visitor observation is // needed. Remember its presence as a coarse lifetime marker after consent. if (features.custom_css.configured) { - await this.markUsed(['custom_css']); + if (persist) await this.markUsed(['custom_css']); features.custom_css.used = true; } const now = new Date(this.now()).toISOString(); @@ -797,7 +929,7 @@ class UsageService { const state = await this.state(); if (state.status !== 'active') throw new ConflictError('Usage participation is not active'); - return this.snapshot(); + return this.snapshot(null, { persist: false }); } async command(action, payload) { let receipt; @@ -894,10 +1026,23 @@ class UsageService { kind: 'export', receipt_id: crypto.randomUUID(), confirmed_at: new Date(this.now()).toISOString(), + // Reports only. Counting every packet — feedback, votes, portal + // sessions, the registration — and labelling the total "usage + // reports" made a privacy receipt state something untrue about + // its own contents, which is exactly the document that has to be + // exact. `packet_count` keeps the total available alongside it. report_count: Array.isArray(result.packets) + ? result.packets.filter( + (envelope) => envelope?.packet?.action === 'report' + ).length + : 0, + packet_count: Array.isArray(result.packets) ? result.packets.length : 0, - scope: ['unique accepted usage reports'] + scope: [ + 'accepted usage reports', + 'accepted participant operations' + ] } }) }); diff --git a/backend/src/usage/expandedSnapshot.js b/backend/src/usage/expandedSnapshot.js index dea7c7ea..53ffe305 100644 --- a/backend/src/usage/expandedSnapshot.js +++ b/backend/src/usage/expandedSnapshot.js @@ -91,12 +91,24 @@ async function expandSnapshot(db, { features, flags, used, now }) { result.gallery_expiration.configured = await exists('events', ['expires_at'], (query) => query.whereNotNull('expires_at')); result.download_resolution_picker.configured = truth(settings.download_resolution_picker_enabled) || await enabled('events', 'download_resolution_picker_enabled'); - result.gallery_image_protection.configured = ['standard', 'enhanced', 'maximum'].includes(settings.default_protection_level) || - truth(settings.enable_devtools_protection) || truth(settings.enable_canvas_rendering); - for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) + // Only what an operator actually changed. PicPeak ships + // default_protection_level='standard' and enable_devtools_protection=true — + // globally and on every event row — so accepting either as evidence made + // this signal `true` on a bare install with no galleries at all. It reported + // fleet-wide 100% and could never separate a deliberate configuration from + // an untouched one, which is a field that costs consent budget and explains + // nothing. `enable_devtools_protection` is therefore not read at all: being + // on by default, its only informative state is off, which is the opposite + // of what this key claims. The remaining inputs each ship off ('standard' + // protection, no canvas rendering, right-click allowed), so a true here is + // always a decision someone made. + result.gallery_image_protection.configured = + ['enhanced', 'maximum'].includes(settings.default_protection_level) || + truth(settings.enable_canvas_rendering); + for (const column of ['disable_right_click', 'use_canvas_rendering']) result.gallery_image_protection.configured ||= await enabled('events', column); result.gallery_image_protection.configured ||= await exists('events', ['protection_level'], (query) => - query.whereIn('protection_level', ['standard', 'enhanced', 'maximum'])); + query.whereIn('protection_level', ['enhanced', 'maximum'])); for (const [suffix, column] of Object.entries({ likes: 'allow_likes', ratings: 'allow_ratings', comments: 'allow_comments', favorites: 'allow_favorites', reactions: 'allow_reactions', color_labels: 'allow_color_labels' diff --git a/backend/src/usage/features.v2.json b/backend/src/usage/features.v2.json index a7e08eed..9e51dd48 100644 --- a/backend/src/usage/features.v2.json +++ b/backend/src/usage/features.v2.json @@ -1252,8 +1252,8 @@ "de": "Bildschutz aktiviert" }, "configured": { - "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", - "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + "en": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts.", + "de": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." }, "used": null }, diff --git a/docs/FEATURE_COVERAGE.md b/docs/FEATURE_COVERAGE.md index 5ca0694d..12a6fc27 100644 --- a/docs/FEATURE_COVERAGE.md +++ b/docs/FEATURE_COVERAGE.md @@ -133,7 +133,7 @@ Legacy v1 semantics remain documented separately in the protocol reference. | `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | | `gallery_client_access` — Client access enabled / Client-Zugang aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | | `gallery_watermarks` — Watermarks enabled / Wasserzeichen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | -| `gallery_image_protection` — Image protection enabled / Bildschutz aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_image_protection` — Image protection enabled / Bildschutz aktiviert | usage.v2 | Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | | `gallery_reveal` — Gallery reveal enabled / Galerie-Enthüllung aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | | `gallery_expiration` — Gallery expiration configured / Galerieablauf konfiguriert | usage.v2 | At least one gallery has an expiry configured; no dates, gallery IDs or counts. | **Not collected. Configuration only.** | diff --git a/docs/PRODUCT_USAGE.md b/docs/PRODUCT_USAGE.md index d60036fa..fecc64f6 100644 --- a/docs/PRODUCT_USAGE.md +++ b/docs/PRODUCT_USAGE.md @@ -45,7 +45,15 @@ defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated `USAGE_ENCRYPTION_KEY` first loses it. The settings page then reports `SIGNING_KEY_UNREADABLE` rather than a generic delivery failure, because the consequence is specific: reports stop and the deletion request can no longer -be signed either. Keys live in a dedicated database +be signed either. Restoring the original key material is the correct fix and +completes the pending deletion. When it is genuinely gone — a rotation done +because the secret was compromised — the settings page offers **Discard local +identity** (`POST /api/admin/usage/abandon`), which is available in no other +state. It erases the local identity, key material and markers and records an +abandonment receipt marked `collector-unconfirmed`: the collector was never +told, so it keeps the reports already accepted, and the receipt says so rather +than claiming a deletion that did not happen. Participation can be started +again afterwards with a fresh identity. Keys live in a dedicated database table, not the generic readable settings. A random mode-0600 file at `getStoragePath()/usage-instance.key` binds the database to its local storage. @@ -75,6 +83,16 @@ durable and retried. Multiple admin tabs/processes share a database lease; only accepted receipts advance the sequence and report date. Re-signed retries reuse the immutable packet ID so lost acknowledgements do not duplicate data. +Retries are paced (migration 206). Consecutive failures set `attempts` and +`next_attempt_at`, and the unattended sender — the activity endpoint and the +settings ticker — waits for that gate: 2, 4, 8, 16, 32 minutes, then hourly. +Without it a packet the collector rejects permanently produced one collector +request per admin action, because any authenticated admin reaches the activity +endpoint and every open admin tab fires it every five minutes. Explicit +operator actions are not paced: **Retry** and opt-out send immediately, and the +settings page names the time of the next automatic attempt so a waiting +installation does not read as a broken one. + Opt-out immediately stops collection, clears markers/previews/feedback preferences, and enters deletion pending. It keeps only credentials and the deletion operation until the collector confirms deletion. The collector removes @@ -84,9 +102,12 @@ a fresh identity. Repeated deletion handles lost receipts safely. Migration 204 adds bounded, local-only privacy receipts and removes any legacy plaintext voting token from the last collector receipt. A completed export -records its time and report count; confirmed opt-out replaces this with a -deletion receipt containing only a random receipt ID, time, status and fixed -scope. It retains no old installation hash, key, payload or credential. The +records its time, the number of accepted reports and the total number of +accepted packets separately — feedback, votes and portal sessions are +participant operations, not reports, and a receipt that folded them into one +"reports" figure stated something untrue about its own contents. Confirmed +opt-out replaces this with a deletion receipt containing only a random receipt +ID, time, status and fixed scope. It retains no old installation hash, key, payload or credential. The settings page can download these receipts even after opt-out. They are local records of the collector acknowledgement, not independent proof of storage erasure. Downloaded exports carry their own dated receipt; the collector does @@ -105,6 +126,14 @@ feedback preferences. Any authenticated admin may trigger the fixed daily report; the activity endpoint accepts no telemetry input. Every usage endpoint uses adminAuth, including token-type checks. Gallery tokens cannot use it. +Feedback, votes and portal sessions share one installation-wide budget of 30 +per hour. They are the only endpoints whose effect is an outbound request +carrying operator-written free text, and the platform's general limiter skips +authenticated requests by design — correct for endpoints that touch only this +installation, wrong for a relay. Reading status, retrying and opting out are +never throttled: those are how an operator sees what is happening and how they +leave. + Feedback is sent only on explicit submission. Each item defaults anonymous and private; names, publication permission, and testimonial marketing permission are separate choices. Published requests/testimonials require maintainer review. diff --git a/docs/usage-coverage.v2.json b/docs/usage-coverage.v2.json index f7aa0264..93b483db 100644 --- a/docs/usage-coverage.v2.json +++ b/docs/usage-coverage.v2.json @@ -1218,7 +1218,7 @@ "adminUsage.js": { "decision": "excluded", "signals": [], - "reason": "Consent, inspection, export, feedback, voting and deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.", + "reason": "Consent, inspection, export, feedback, voting, deletion and abandoning an unsignable deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.", "route_signatures": [ "POST /activity", "GET /", @@ -1226,6 +1226,7 @@ "POST /enable", "POST /consent", "POST /disable", + "POST /abandon", "POST /retry", "GET /preview", "GET /export", diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 37439c25..0072df9f 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -31,6 +31,7 @@ vi.mock('../../../services/productUsage.service', () => ({ upgradeConsent: vi.fn(), disable: vi.fn(), retry: vi.fn(), + abandon: vi.fn(), preview: vi.fn(), export: vi.fn(), preferences: vi.fn(), @@ -216,3 +217,90 @@ describe('product usage controls', () => { await waitFor(() => expect(service.retry).toHaveBeenCalled()); }); }); + +describe('a withdrawal that can never be signed', () => { + const stuck: UsageStatus = { + ...status, + status: 'deletion_pending', + installation_id: 'a'.repeat(64), + schema_version: 'usage.v2', + last_error: 'SIGNING_KEY_UNREADABLE', + can_abandon: true + }; + + it('explains the dead end and offers the only remaining exit', async () => { + vi.mocked(service.status).mockResolvedValue(stuck); + vi.mocked(service.abandon).mockResolvedValue({ ...status }); + mount(); + + // The operator is told what happened before being offered the exit. + await screen.findByText('productUsage.signingKeyUnreadable'); + await screen.findByText('productUsage.abandonExplanation'); + fireEvent.click(await screen.findByText('productUsage.abandon')); + await waitFor(() => expect(service.abandon).toHaveBeenCalledTimes(1)); + }); + + it('does not offer it for a withdrawal that is merely undelivered', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...stuck, + last_error: 'DELIVERY_FAILED', + can_abandon: false + }); + mount(); + await screen.findByText('productUsage.deliveryProblem'); + expect(screen.queryByText('productUsage.abandon')).toBeNull(); + }); +}); + +it('says the sender is waiting rather than leaving a bare error on screen', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active', + schema_version: 'usage.v2', + installation_id: 'a'.repeat(64), + last_error: 'DELIVERY_FAILED', + retry_after: Date.now() + 600000 + }); + mount(); + await screen.findByText('productUsage.retryScheduled'); +}); + +it('marks a deletion receipt as belonging to an earlier participation', async () => { + const receipts = { last_deletion: { kind: 'deletion' } }; + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active', + schema_version: 'usage.v2', + installation_id: 'a'.repeat(64), + privacy_receipts: receipts + }); + mount(); + await screen.findByText('productUsage.auditPreviousParticipation'); + + cleanup(); + // Withdrawn: the same receipt now describes the participation just ended, + // so the qualifier would be wrong. + vi.mocked(service.status).mockResolvedValue({ ...status, privacy_receipts: receipts }); + mount(); + await screen.findByText('productUsage.auditTitle'); + expect(screen.queryByText('productUsage.auditPreviousParticipation')).toBeNull(); +}); + +it('returns focus to the control that opened the consent dialog', async () => { + mount(); + const trigger = await screen.findByText('productUsage.review'); + trigger.focus(); + expect(document.activeElement).toBe(trigger); + + fireEvent.click(trigger); + await screen.findByText('productUsage.consentTitle'); + fireEvent.click(screen.getByText('productUsage.cancel')); + + // Without the restore this lands on
, dropping a keyboard user back + // to the top of the page (WCAG 2.4.3). + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByText('productUsage.review') + ) + ); +}); diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index d91f6351..fb3db9f6 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -36,6 +36,13 @@ const DISCLOSURE: { { key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare } ]; +// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for +// short labels, wrong for the sentence-length ones in this tab, which ran off +// the card at 390px and then, once allowed to wrap, out of the fixed height. +// h-auto lets the second line have somewhere to go; min-h keeps a one-line +// button the same size as every other button beside it. +const WRAPPING_BUTTON = 'max-w-full whitespace-normal text-left h-auto min-h-[2.5rem]'; + function ConsentDialog({ close, enable, @@ -53,6 +60,12 @@ function ConsentDialog({ const ref = useRef