fix(usage): introduce consented v4 without changing historical reports

This commit is contained in:
Paul Nothaft
2026-09-06 21:34:04 +02:00
parent 3cc893126d
commit ef8a52f02c
21 changed files with 5046 additions and 197 deletions
@@ -1,8 +1,8 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const catalog = require('../../src/usage/features.v3.json');
const inventory = require('../../../docs/usage-coverage.v3.json');
const catalog = require('../../src/usage/features.v4.json');
const inventory = require('../../../docs/usage-coverage.v4.json');
const protocol = require('../../src/usage/schema.cjs');
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
@@ -52,16 +52,22 @@ test('all current settings tabs have an explicit scope decision', () => {
}
});
test('v1 wire validation is immutable; catalog, UI and translated descriptions agree', () => {
test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descriptions agree', () => {
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
expect(protocol.FEATURE_KEYS).toHaveLength(86);
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(87);
expect(protocol.ALL_FEATURE_KEYS).toContain('gallery_downloads');
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
expect(inventory.configuration_only).toHaveLength(23);
const frontend = path.resolve(__dirname, '../../../frontend');
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v3.json')))).toEqual(catalog);
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v4.json')))).toEqual(catalog);
for (const lang of ['en', 'de']) {
const translated = JSON.parse(fs.readFileSync(path.join(frontend, `src/i18n/locales/${lang}.json`))).productUsage.catalog;
for (const [key, value] of Object.entries(catalog.features)) {
@@ -7,13 +7,13 @@ const signHistorical = (packet, id) => {
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
};
describe.each(['usage.v1', 'usage.v2', 'usage.v3'])('%s receiver compatibility never loosens the PicPeak sender', version => {
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4'])('%s receiver compatibility never loosens the PicPeak sender', version => {
test('complete original reports still sign and verify', () => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'report', 1, {
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
features: p.emptyFeatures(version), gallery_layouts: [],
...(version === 'usage.v3' ? { inventory: { galleries: 0, photos: 0 } } : {}),
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
}, version);
const envelope = p.signPacket(packet, id, new Date(now));
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
+35 -64
View File
@@ -62,7 +62,7 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
expect(queries.filter(sql => /from ["`]photos["`]/.test(sql))).toEqual([expect.stringMatching(/select count\(\*\)/)]);
expect(JSON.stringify(report)).not.toContain('PRIVATE');
const identity = p.generateIdentity();
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report), identity, new Date(now));
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report, 'usage.v3'), identity, new Date(now));
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
await db('photos').where({ id: 1 }).delete();
await db('events').where({ id: 1 }).delete();
@@ -126,69 +126,6 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
expect((await snap({})).gallery_folders.configured).toBe(false);
});
test('gallery_downloads_restricted counts galleries with downloads switched off, and v2 keeps its old key', async () => {
// allow_downloads ships true, so the v2 key was true on every install
// with a gallery. Only switching downloads off is a decision.
const snap = version => expandSnapshot(db, { features: p.emptyFeatures('usage.v1'), flags: {}, used: new Set(), now, version });
expect((await snap('usage.v3')).gallery_downloads_restricted).toEqual({ configured: false });
expect(await snap('usage.v3')).not.toHaveProperty('gallery_downloads');
await db('events').insert([{ allow_downloads: true }, { allow_downloads: true }]);
expect((await snap('usage.v3')).gallery_downloads_restricted.configured).toBe(false);
expect((await snap('usage.v2')).gallery_downloads).toEqual({ configured: true });
expect(await snap('usage.v2')).not.toHaveProperty('gallery_downloads_restricted');
await db('events').insert({ allow_downloads: false });
expect((await snap('usage.v3')).gallery_downloads_restricted.configured).toBe(true);
expect((await snap('usage.v2')).gallery_downloads.configured).toBe(true);
});
test('a report queued under the replaced catalog is rebuilt in place, keeping its packet id', async () => {
const identity = p.generateIdentity();
const posted = [];
const service = new UsageService(db, {
now: () => now, secret: 'v3-test-only-secret'.repeat(3), endpoint: 'http://127.0.0.1:9/',
fetch: async (_url, init) => { posted.push(JSON.parse(init.body).packet); throw new Error('collector unreachable'); },
});
service.binding = async () => 'b'.repeat(64);
const report = (features) => ({
picpeak_version: '1.0.0', report_date: '2026-09-05', generated_at: new Date(now).toISOString(),
features, gallery_layouts: [], inventory: { galleries: 0, photos: 0 },
});
// The v3 catalog as it stood before gallery_downloads_restricted replaced gallery_downloads.
const { gallery_downloads_restricted, ...rest } = p.emptyFeatures('usage.v3');
const stale = { ...rest, gallery_downloads: gallery_downloads_restricted };
const seed = (payload) => 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, last_error: null, attempts: 3, next_attempt_at: now + 60_000,
pending_packet: JSON.stringify(p.makePacket(identity, 'report', 2, payload, 'usage.v3')),
});
await seed(report(stale));
const queued = JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).pending_packet);
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
// Sent once, under the current catalog, as the same packet.
expect(posted).toHaveLength(1);
expect(posted[0].packet_id).toBe(queued.packet_id);
expect(posted[0].sequence).toBe(2);
expect(posted[0].payload.features).toHaveProperty('gallery_downloads_restricted');
expect(posted[0].payload.features).not.toHaveProperty('gallery_downloads');
// The rebuilt packet is what stays queued for the ordinary retry path.
let row = await db('product_usage_state').where({ id: 1 }).first();
const retained = JSON.parse(row.pending_packet);
expect(retained.packet_id).toBe(queued.packet_id);
expect(retained.payload.features).toHaveProperty('gallery_downloads_restricted');
expect(row.status).toBe('active');
expect(row.last_error).toBe('DELIVERY_FAILED');
// Narrow: a report that still validates is sent as queued, payload untouched.
await seed(report(p.emptyFeatures('usage.v3')));
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
expect(posted).toHaveLength(2);
expect(posted[1].payload.report_date).toBe('2026-09-05');
row = await db('product_usage_state').where({ id: 1 }).first();
expect(JSON.parse(row.pending_packet).payload.report_date).toBe('2026-09-05');
});
test('ML recognition is already represented without querying faces or results', async () => {
await db('feature_flags').insert({ key: 'faces', value: true });
await client.markUsed(['face_recognition']);
@@ -197,5 +134,39 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
expect((await client.snapshot()).features.face_recognition).toEqual({ configured: false, used: true });
// No faces, people, embeddings or recognition-result tables exist in this fixture.
});
test.each([
[[], false, false], [[true], true, false], [[false], false, true],
[[true, false], true, true], [[null], false, false],
])('v4 measures explicit restrictions independently from legacy allowed downloads: %p', async (values, allowed, restricted) => {
if (values.length) await db('events').insert(values.map(allow_downloads => ({ allow_downloads })));
const queries = [];
db.on('query', q => queries.push(q));
for (const version of ['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4']) {
await db('product_usage_state').where({ id: 1 }).update({ consent_version: p.CONSENT_VERSIONS[version] });
queries.length = 0;
const report = await client.preview();
const downloadQueries = queries.filter(q => /where ["`]allow_downloads["`] =/.test(q.sql));
if (version === 'usage.v4') {
expect(report.features.gallery_downloads_restricted).toEqual({ configured: restricted });
expect(report.features).not.toHaveProperty('gallery_downloads');
expect(report.inventory).toEqual({ galleries: values.length, photos: 0 });
expect(downloadQueries).toHaveLength(1);
expect(downloadQueries[0].sql).toMatch(/select 1 as present/);
expect(Number(downloadQueries[0].bindings[0])).toBe(0);
} else {
expect(report.features).not.toHaveProperty('gallery_downloads_restricted');
if (version === 'usage.v1') expect(downloadQueries).toHaveLength(0);
else {
expect(report.features.gallery_downloads).toEqual({ configured: allowed });
expect(downloadQueries).toHaveLength(1);
expect(Number(downloadQueries[0].bindings[0])).toBe(1);
}
}
const identity = p.generateIdentity();
const envelope = p.signPacket(p.makePacket(identity, 'report', 1, report, version), identity, new Date(now));
expect(p.verifyEnvelope(envelope, now).payload).toEqual(report);
}
});
});
}