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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user