fix(usage): introduce consented v4 without changing historical reports
This commit is contained in:
@@ -201,13 +201,13 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||
});
|
||||
|
||||
test('consent upgrade accepts exactly the explicit v2 choice, never extra fields', async () => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v2', user: 'PRIVATE' }])
|
||||
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v5' }, { consent_version, user: 'PRIVATE' }])
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
|
||||
expect(service.command).not.toHaveBeenCalled();
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
|
||||
.send({ consent_version: 'usage-consent.v2' }).expect(200);
|
||||
expect(service.command).toHaveBeenCalledWith('consent', { consent_version: 'usage-consent.v2' });
|
||||
.send({ consent_version }).expect(200);
|
||||
expect(service.command).toHaveBeenCalledWith('consent', { consent_version });
|
||||
});
|
||||
|
||||
test('only a backup that writes to the configured destination flags S3', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user