feat(security): harden the API, trust Hiboutik for deletions, lock syncs (#3)

- Sync deletions: a 2xx Hiboutik answer is the truth, even an empty list; a failed call (HTTP error or non-list body) deletes nothing (HiboutikClient.getList).
- Validate the environment at startup (HIBOUTIK_BASE_URL must be HTTPS), global ValidationPipe, Helmet, CORS opt-in, HTTP timeouts, rate limiting (60 req/min/IP, 5 on POST /sync), AllExceptionsFilter.
- Postgres advisory lock so only one synchronization runs at a time across instances; syncAll is private, syncs start through startSync.
- Fix the Jest moduleNameMapper; document the validation and security rules in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 09:36:50 +00:00
parent 8b341c4933
commit 8c8a2ccf15
31 changed files with 923 additions and 499 deletions

View File

@@ -0,0 +1,39 @@
import 'reflect-metadata';
import { validateEnv } from './env.validation';
describe('validateEnv', () => {
const valid = {
DATABASE_URL: 'postgresql://user:secret@localhost:5432/db',
HIBOUTIK_BASE_URL: 'https://shop.hiboutik.com/api',
HIBOUTIK_USERNAME: 'user',
HIBOUTIK_PASSWORD: 'secret-password',
};
it('accepts a valid configuration and keeps every variable', () => {
const env = { ...valid, PORT: '3000', CORS_ORIGINS: 'https://shop.example.com', TRUST_PROXY: '1', OTHER: 'x' };
expect(validateEnv(env)).toBe(env);
});
it('rejects a missing required variable, naming it', () => {
expect(() => validateEnv({ ...valid, HIBOUTIK_PASSWORD: undefined })).toThrow(/HIBOUTIK_PASSWORD/);
});
it('rejects a non-HTTPS Hiboutik URL', () => {
expect(() => validateEnv({ ...valid, HIBOUTIK_BASE_URL: 'http://shop.hiboutik.com/api' })).toThrow(/HIBOUTIK_BASE_URL/);
});
it('rejects an invalid port', () => {
expect(() => validateEnv({ ...valid, PORT: 'abc' })).toThrow(/PORT/);
expect(() => validateEnv({ ...valid, PORT: '70000' })).toThrow(/PORT/);
});
it('never puts secret values in the error message', () => {
expect.assertions(2);
try {
validateEnv({ ...valid, HIBOUTIK_BASE_URL: 'http://insecure', DATABASE_URL: '' });
} catch (error) {
expect((error as Error).message).not.toContain('secret-password');
expect((error as Error).message).not.toContain('secret');
}
});
});