feat: harden generic preset, add LLM retry, and finish tooling/docs
- PatternDef.validate + Luhn-gated credit-card detection; de-overlap the generic phone/date/IP patterns; presets.swiss unchanged (production behavior) - strip g/y flags from nameHint so .test() is stateless (latent footgun) - openAICompatibleProvider: bounded retry on transient failures (network / timeout / 429 / 5xx), configurable via retries + retryDelayMs - eslint + prettier + vitest coverage (97%); CI runs lint/format/coverage - docs: README badges + new-option docs, SECURITY.md, issue/PR templates 26 tests passing; build emits ESM+CJS+types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,13 +46,71 @@ describe('openAICompatibleProvider', () => {
|
||||
expect(system).toContain('[PER_1.NOM:M]');
|
||||
});
|
||||
|
||||
it('throws on a non-OK HTTP status', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||
await expect(openAICompatibleProvider(opts).anonymize('x')).rejects.toThrow('LLM_HTTP_500');
|
||||
it('throws on a non-OK HTTP status (no retry when retries: 0)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(openAICompatibleProvider({ ...opts, retries: 0 }).anonymize('x')).rejects.toThrow(
|
||||
'LLM_HTTP_500',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws on a malformed response shape', async () => {
|
||||
vi.stubGlobal('fetch', mockFetchJson({ wrong: true }));
|
||||
await expect(openAICompatibleProvider(opts).anonymize('x')).rejects.toThrow('LLM_BAD_SHAPE');
|
||||
await expect(openAICompatibleProvider({ ...opts, retries: 0 }).anonymize('x')).rejects.toThrow(
|
||||
'LLM_BAD_SHAPE',
|
||||
);
|
||||
});
|
||||
|
||||
describe('retry/backoff', () => {
|
||||
const retryOpts = { ...opts, retries: 1, retryDelayMs: 0 };
|
||||
|
||||
it('retries once on a transient 500 then succeeds (2 calls)', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 500 })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ texte_anonymise: 'ok', mapping: {} }) } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const r = await openAICompatibleProvider(retryOpts).anonymize('x');
|
||||
expect(r.anon).toBe('ok');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries on a network error then succeeds', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ECONNRESET'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ texte_anonymise: 'ok', mapping: {} }) } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const r = await openAICompatibleProvider(retryOpts).anonymize('x');
|
||||
expect(r.anon).toBe('ok');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does NOT retry a 400 (non-transient)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(openAICompatibleProvider(retryOpts).anonymize('x')).rejects.toThrow('LLM_HTTP_400');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up after exhausting retries on persistent 503', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(openAICompatibleProvider(retryOpts).anonymize('x')).rejects.toThrow('LLM_HTTP_503');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2); // initial + 1 retry
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
50
test/presets.test.ts
Normal file
50
test/presets.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Anonymizer, presets } from '../src/index.js';
|
||||
|
||||
describe('generic preset', () => {
|
||||
const a = new Anonymizer({ patterns: presets.generic });
|
||||
|
||||
it('redacts a Luhn-valid credit card', async () => {
|
||||
const r = await a.anonymize('Carte 4111 1111 1111 1111 acceptée'); // valid test Visa
|
||||
expect(r.anon).not.toContain('4111');
|
||||
expect(Object.values(r.mapping)).toContain('4111 1111 1111 1111');
|
||||
expect(Object.keys(r.mapping)[0]).toMatch(/^\[CREDIT_CARD_1\]$/);
|
||||
});
|
||||
|
||||
it('leaves a Luhn-INVALID digit run intact (not tagged as a card)', async () => {
|
||||
// Unseparated run: no other generic pattern matches it either, so it stays verbatim.
|
||||
const r = await a.anonymize('Ref 1234567890123456 interne'); // fails Luhn
|
||||
expect(r.anon).toContain('1234567890123456');
|
||||
expect(r.mapping).toEqual({});
|
||||
});
|
||||
|
||||
it('redacts an IPv4 address without mangling it as a phone or card', async () => {
|
||||
const r = await a.anonymize('Serveur 192.168.1.42 indisponible');
|
||||
expect(r.mapping).toEqual({ '[IPV4_1]': '192.168.1.42' });
|
||||
expect(a.deanonymize(r.anon, r.mapping)).toContain('192.168.1.42');
|
||||
});
|
||||
|
||||
it('tags a date as DATE (not TEL) and keeps a phone separate', async () => {
|
||||
const r = await a.anonymize('Le 12.03.2024, appelez le +41 22 345 67 89.');
|
||||
const tags = Object.keys(r.mapping).map((p) => p.replace(/_\d+\]$/, ']'));
|
||||
expect(tags).toContain('[DATE]');
|
||||
expect(tags).toContain('[TEL]');
|
||||
expect(r.mapping['[DATE_1]']).toBe('12.03.2024');
|
||||
// round-trip is lossless
|
||||
expect(a.deanonymize(r.anon, r.mapping)).toBe('Le 12.03.2024, appelez le +41 22 345 67 89.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nameHint stability', () => {
|
||||
it('a global-flagged custom nameHint yields the same result across repeated calls', async () => {
|
||||
// A naive `.test()` on a /g regex alternates true/false; the Anonymizer must strip the flag.
|
||||
const a = new Anonymizer({ nameHint: /\bDr\.\s\w+/g, patterns: presets.swiss });
|
||||
const text = 'Rendez-vous avec Dr. Meyer'; // no structured PII, only the name hint
|
||||
const r1 = await a.anonymize(text);
|
||||
const r2 = await a.anonymize(text);
|
||||
const r3 = await a.anonymize(text);
|
||||
// No LLM → nothing is actually redacted, but behavior must be identical every time.
|
||||
expect(r1).toEqual(r2);
|
||||
expect(r2).toEqual(r3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user