Files
picpeak/backend/__tests__/routes/adminUsage.test.js
T
Paul Nothaft b798d8e4c1 fix(backend): use the strong password generator for resets and enforce must_change_password (#1387)
Admin password reset generated a ~2^21-entropy password from a small
wordlist instead of the already-available generateSecurePassword(16),
and must_change_password was written on reset but never checked by
any route-blocking logic — a reset user could keep using the old
session/password indefinitely.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-11 10:40:46 +02:00

337 lines
12 KiB
JavaScript

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',
'markPromptShown',
'enable',
'disable',
'abandon',
'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 { productUsageApi } = 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');
// adminAuth() now selects this on every request (GHSA-h4w8-57xq-53fx
// must_change_password enforcement) — without the column the join
// throws and every route in this file 401s before reaching the
// permission check it's meant to test.
t.boolean('must_change_password');
});
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: 'owner@example.test',
role_id: 1,
is_active: 1
},
{
id: 2,
username: 'viewer',
email: 'viewer@example.test',
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());
test('scoped API use records only its fixed v2 capability and never triggers a report', () => {
const simulate = (admin, apiToken, statusCode) => {
const res = new (require('events').EventEmitter)(); res.statusCode = statusCode;
productUsageApi({ admin, apiToken, body: { user: 'PRIVATE@example.test' } }, res, () => {});
res.emit('finish');
};
simulate(null, { id: 99 }, 200);
simulate({ id: 42 }, null, 200);
simulate({ id: 42 }, { id: 99 }, 403);
expect(service.markUsed).not.toHaveBeenCalled();
simulate({ id: 42 }, { id: 99 }, 200);
expect(service.markUsed).toHaveBeenCalledWith(['api_integration'], { legacyFeatures: [] });
expect(service.tick).not.toHaveBeenCalled();
expect(JSON.stringify(service.markUsed.mock.calls)).not.toMatch(/PRIVATE|42|99/);
});
const ROUTES = [
['get', '/'],
['post', '/activity'],
['post', '/enable'],
['post', '/consent'],
['post', '/disable'],
['post', '/abandon'],
['post', '/retry'],
['post', '/dismiss'],
['post', '/prompt-seen'],
['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('only a settings editor can acknowledge the prompt without opting in', async () => {
await request(app)
.post('/api/admin/usage/prompt-seen')
.set('Authorization', `Bearer ${token('admin')}`)
.expect('Cache-Control', 'no-store')
.expect(200);
expect(service.markPromptShown).toHaveBeenCalledTimes(1);
expect(service.enable).not.toHaveBeenCalled();
});
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.locals = {};
res.statusCode = statusCode;
productUsage({ path, method: 'POST', 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);
// The second argument tells markUsed whether this operation writes to the
// configured backup destination; a CRM route never does.
expect(service.markUsed).toHaveBeenCalledWith(
expect.arrayContaining(['crm', 'crm_hours']),
expect.objectContaining({ destinationBackup: false })
);
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
});
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4', 'usage-consent.v5'])('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.v6' }, { 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 }).expect(200);
expect(service.command).toHaveBeenCalledWith('consent', { consent_version });
});
test('only a backup that writes to the configured destination flags S3', () => {
// /database-backup/* and /backup/picpeak/export produce a local file, so
// they must not imply S3 use just because S3 is the configured destination.
const seen = [];
const simulate = (pathname) => {
service.markUsed.mockClear();
const res = new (require('events').EventEmitter)();
res.locals = {};
res.statusCode = 200;
productUsage({ path: pathname, method: pathname.endsWith('/export') ? 'GET' : 'POST', admin: { id: 1 } }, res, () => {});
res.emit('finish');
seen.push([pathname, service.markUsed.mock.calls[0]?.[1]?.destinationBackup]);
};
simulate('/backup/run');
simulate('/database-backup/backup');
simulate('/backup/picpeak/export');
expect(seen).toEqual([
['/backup/run', true],
['/database-backup/backup', false],
['/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);
});