feat: add opt-in product usage and feedback integration (#1110)

This commit is contained in:
Paul Nothaft
2026-09-05 12:59:06 +02:00
parent c71ffae912
commit b53e5d97b4
24 changed files with 2360 additions and 18 deletions
+178
View File
@@ -0,0 +1,178 @@
const request = require('supertest');
const express = require('express');
const jwt = require('jsonwebtoken');
const mockDb = require('knex')({
client: 'sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true
});
jest.mock('../../src/database/db', () => ({
get db() {
return mockDb;
}
}));
jest.mock('../../src/utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn().mockResolvedValue(false)
}));
jest.mock('../../src/utils/sessionCutoff', () => ({
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false)
}));
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
info: jest.fn()
}));
jest.mock('../../src/services/productUsageService', () =>
Object.fromEntries(
[
'tick',
'status',
'dismiss',
'enable',
'disable',
'preview',
'export',
'preferences',
'command',
'markUsed'
].map((key) => [key, jest.fn().mockResolvedValue({ status: 'disabled' })])
)
);
const service = require('../../src/services/productUsageService');
const { productUsage } = require('../../src/middleware/productUsage');
const SECRET = 'usage-auth-test-secret-not-a-live-credential';
const token = (type, id = 1) =>
jwt.sign({ type, id }, SECRET, {
issuer: 'picpeak-auth',
algorithm: 'HS256'
});
let app;
beforeAll(async () => {
process.env.JWT_SECRET = SECRET;
await mockDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name');
});
await mockDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username');
t.string('email');
t.integer('role_id');
t.boolean('is_active');
t.timestamp('password_changed_at');
});
await mockDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name');
});
await mockDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id');
t.integer('permission_id');
});
await mockDb('roles').insert([
{ id: 1, name: 'super_admin' },
{ id: 2, name: 'viewer' }
]);
await mockDb('admin_users').insert([
{
id: 1,
username: 'owner',
email: '[email protected]',
role_id: 1,
is_active: 1
},
{
id: 2,
username: 'viewer',
email: '[email protected]',
role_id: 2,
is_active: 1
}
]);
await mockDb('permissions').insert({ id: 1, name: 'settings.edit' });
await mockDb('role_permissions').insert({ role_id: 1, permission_id: 1 });
app = express();
app.use(express.json());
app.use('/api/admin/usage', require('../../src/routes/adminUsage'));
app.use((err, _req, res, _next) =>
res.status(err.statusCode || 500).json({ code: err.code })
);
});
afterAll(() => mockDb.destroy());
beforeEach(() => jest.clearAllMocks());
const ROUTES = [
['get', '/'],
['post', '/activity'],
['post', '/enable'],
['post', '/disable'],
['post', '/retry'],
['post', '/dismiss'],
['get', '/preview'],
['get', '/export'],
['put', '/feedback-preferences'],
['post', '/feedback'],
['post', '/vote'],
['post', '/portal-session']
];
test.each(ROUTES)(
'%s %s rejects unauthenticated and gallery tokens',
async (method, route) => {
await request(app)[method](`/api/admin/usage${route}`).send({}).expect(401);
await request(app)[method](`/api/admin/usage${route}`)
.set('Authorization', `Bearer ${token('gallery')}`)
.send({})
.expect(403);
expect(service.tick).not.toHaveBeenCalled();
expect(service.enable).not.toHaveBeenCalled();
}
);
test.each(ROUTES.filter(([, route]) => route !== '/activity'))(
'%s %s requires settings.edit',
async (method, route) => {
await request(app)[method](`/api/admin/usage${route}`)
.set('Authorization', `Bearer ${token('admin', 2)}`)
.send({})
.expect(403);
}
);
test('an authenticated admin can trigger cadence without seeing identity or packet data', async () => {
const response = await request(app)
.post('/api/admin/usage/activity')
.set('Authorization', `Bearer ${token('admin', 2)}`)
.expect(200);
expect(response.body).toEqual({ ok: true });
expect(service.tick).toHaveBeenCalledTimes(1);
});
test('owner sees no-store status and supplies consent to the service', async () => {
await request(app)
.get('/api/admin/usage')
.set('Authorization', `Bearer ${token('admin')}`)
.expect('Cache-Control', 'no-store')
.expect(200);
await request(app)
.post('/api/admin/usage/enable')
.set('Authorization', `Bearer ${token('admin')}`)
.send({ consent_version: 'usage-consent.v1' })
.expect(200);
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
});
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
const { EventEmitter } = require('events');
const simulate = (path, admin, statusCode) => {
const res = new EventEmitter();
res.statusCode = statusCode;
productUsage({ path, admin }, res, () => {});
res.emit('finish');
};
simulate('/gallery/example', null, 200);
simulate('/customers', null, 200);
simulate('/quotes', { id: 1 }, 403);
expect(service.markUsed).not.toHaveBeenCalled();
simulate('/customers/42/hour-entries', { id: 1 }, 200);
expect(service.markUsed).toHaveBeenCalledWith(
expect.arrayContaining(['crm', 'crm_hours'])
);
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
});