feat(testing): add vitest suite + guerrilla mail email pool

- Vitest unit tests for POST /api/erase: validation, form-only
  providers, all email providers, CRLF sanitization
- Snapshot on PROVIDER_DATA to catch provider config drift
- Mock via require cache to avoid nodemailer/sendmail dependency
- sendErasureMail call assertions (args + call count)
- fetch-test-emails.mjs script to refresh disposable email pool
- mailer: add Reply-To header pointing to requester email
- mailer: fallback to direct transport when sendmail unavailable
This commit is contained in:
robacheque 2026-04-17 22:47:06 +02:00
parent 93d75ddafe
commit 15faa5b136
7 changed files with 343 additions and 10 deletions

View file

@ -0,0 +1,80 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`provider registry > matches snapshot 1`] = `
{
"amazon": {
"address": "38 avenue John F. Kennedy, L-1855 Luxemburgo",
"company": "Amazon Europe Core S.à r.l.",
"email": null,
"formUrl": "https://www.amazon.es/hz/contact-us/privacy-disclosure/",
"name": "Amazon",
},
"apple": {
"address": "Hollyhill Industrial Estate, Hollyhill, Cork, Irlanda",
"company": "Apple Distribution International Ltd.",
"email": "privacy@apple.com",
"name": "Apple",
},
"discord": {
"address": "444 De Haro Street, Suite 200, San Francisco, CA 94107, EE.UU.",
"company": "Discord Inc.",
"email": "privacy@discord.com",
"name": "Discord",
},
"facebook": {
"address": "4 Grand Canal Square, Grand Canal Harbour, Dublín 2, Irlanda",
"company": "Meta Platforms Ireland Limited",
"email": "privacidad-dpo@fb.com",
"name": "Facebook (Meta)",
},
"google": {
"address": "Gordon House, Barrow Street, Dublín 4, Irlanda",
"company": "Google Ireland Limited",
"email": null,
"formUrl": "https://support.google.com/policies/contact/sar_data_protection",
"name": "Google",
},
"instagram": {
"address": "4 Grand Canal Square, Grand Canal Harbour, Dublín 2, Irlanda",
"company": "Meta Platforms Ireland Limited",
"email": "privacidad-dpo@fb.com",
"name": "Instagram (Meta)",
},
"linkedin": {
"address": "Wilton Place, Dublín 2, Irlanda",
"company": "LinkedIn Ireland Unlimited Company",
"email": "privacy@linkedin.com",
"name": "LinkedIn",
},
"microsoft": {
"address": "One Microsoft Place, South County Business Park, Leopardstown, Dublín 18, Irlanda",
"company": "Microsoft Ireland Operations Limited",
"email": "msprivacy@microsoft.com",
"name": "Microsoft",
},
"reddit": {
"address": "548 Market St. #16093, San Francisco, CA 94104, EE.UU.",
"company": "Reddit Inc.",
"email": "gdpr@reddit.com",
"name": "Reddit",
},
"snapchat": {
"address": "77 Shaftesbury Avenue, Londres W1D 5DU, Reino Unido",
"company": "Snap Group Limited",
"email": "privacy@snap.com",
"name": "Snapchat",
},
"tiktok": {
"address": "10 Earlsfort Terrace, Dublín, D02 T380, Irlanda",
"company": "TikTok Technology Limited",
"email": "privacy@tiktok.com",
"name": "TikTok",
},
"twitter_x": {
"address": "One Cumberland Place, Fenian Street, Dublín 2, D02 AX07, Irlanda",
"company": "Twitter International Unlimited Company",
"email": "gdpr@twitter.com",
"name": "X (Twitter)",
},
}
`;

159
api/tests/erase.test.js Normal file
View file

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
import http from 'http';
const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Load real PROVIDER_DATA by stubbing only nodemailer ───────────
// This way the mock never drifts from mailer.js.
const nodemailerPath = require.resolve('nodemailer');
require.cache[nodemailerPath] = {
id: nodemailerPath, filename: nodemailerPath, loaded: true,
exports: { createTransport: () => ({ sendMail: async () => ({}) }) },
children: [], paths: [],
};
const { PROVIDER_DATA } = require('../services/mailer');
// ── Replace mailer in cache with full stub (sendErasureMail mocked) ──
const mockSendErasureMail = vi.fn().mockImplementation(({ provider }) => {
const info = PROVIDER_DATA[provider];
if (!info?.email) return Promise.resolve({ skipped: true, reason: 'use_form', formUrl: info?.formUrl });
return Promise.resolve({ skipped: false });
});
const mailerPath = resolve(__dirname, '../services/mailer.js');
require.cache[mailerPath] = {
id: mailerPath, filename: mailerPath, loaded: true,
exports: { PROVIDER_DATA, sendErasureMail: mockSendErasureMail },
children: [], paths: [],
};
// Load route AFTER cache is patched
const eraseRoute = require('../routes/erase');
const express = require('express');
function buildApp() {
const app = express();
app.use(express.json({ limit: '10kb' }));
app.post('/api/erase', eraseRoute);
return app;
}
function request(app, body) {
return new Promise((resolve, reject) => {
const server = http.createServer(app);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
fetch(`http://127.0.0.1:${port}/api/erase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(res => res.json().then(json => ({ status: res.status, body: json })))
.then(result => { server.close(); resolve(result); })
.catch(err => { server.close(); reject(err); });
});
});
}
let app;
beforeAll(() => { app = buildApp(); });
beforeEach(() => { mockSendErasureMail.mockClear(); });
// ── Snapshot: catch drift in provider list ────────────────────────
describe('provider registry', () => {
it('matches snapshot', () => {
expect(PROVIDER_DATA).toMatchSnapshot();
});
});
// ── Validation ────────────────────────────────────────────────────
describe('validation', () => {
it('rejects missing provider', async () => {
const { status, body } = await request(app, { email: 'test@example.com' });
expect(status).toBe(400);
expect(body.error).toMatch(/provider/i);
expect(mockSendErasureMail).not.toHaveBeenCalled();
});
it('rejects missing email', async () => {
const { status, body } = await request(app, { provider: 'instagram' });
expect(status).toBe(400);
expect(body.error).toMatch(/email/i);
expect(mockSendErasureMail).not.toHaveBeenCalled();
});
it('rejects invalid email format', async () => {
const { status, body } = await request(app, { provider: 'instagram', email: 'not-an-email' });
expect(status).toBe(400);
expect(body.error).toMatch(/email/i);
expect(mockSendErasureMail).not.toHaveBeenCalled();
});
it('rejects unknown provider', async () => {
const { status, body } = await request(app, { provider: 'myspace', email: 'test@example.com' });
expect(status).toBe(400);
expect(body.error).toMatch(/proveedor/i);
expect(mockSendErasureMail).not.toHaveBeenCalled();
});
});
// ── Form-only providers ───────────────────────────────────────────
describe('form-only providers', () => {
it('google returns use_form with formUrl', async () => {
const { status, body } = await request(app, { provider: 'google', email: 'test@example.com' });
expect(status).toBe(200);
expect(body.status).toBe('use_form');
expect(body.formUrl).toMatch(/^https?:\/\//);
expect(mockSendErasureMail).toHaveBeenCalledOnce();
expect(mockSendErasureMail).toHaveBeenCalledWith(expect.objectContaining({ provider: 'google', email: 'test@example.com' }));
});
it('amazon returns use_form with formUrl', async () => {
const { status, body } = await request(app, { provider: 'amazon', email: 'test@example.com' });
expect(status).toBe(200);
expect(body.status).toBe('use_form');
expect(body.formUrl).toMatch(/^https?:\/\//);
expect(mockSendErasureMail).toHaveBeenCalledOnce();
expect(mockSendErasureMail).toHaveBeenCalledWith(expect.objectContaining({ provider: 'amazon', email: 'test@example.com' }));
});
});
// ── Successful sends (mocked transport) ──────────────────────────
describe('email providers', () => {
const providers = Object.entries(PROVIDER_DATA)
.filter(([, info]) => info.email !== null)
.map(([key]) => key);
for (const provider of providers) {
it(`sends for ${provider}`, async () => {
const { status, body } = await request(app, {
provider,
email: 'smoke@example.com',
nickname: 'Test User',
});
expect(status).toBe(200);
expect(body.status).toBe('ok');
expect(body.reference).toHaveLength(12);
expect(mockSendErasureMail).toHaveBeenCalledOnce();
expect(mockSendErasureMail).toHaveBeenCalledWith(expect.objectContaining({ provider, email: 'smoke@example.com', nickname: 'Test User' }));
});
}
});
// ── CRLF injection sanitization ───────────────────────────────────
describe('sanitization', () => {
it('strips CRLF from optional fields without erroring', async () => {
const { status, body } = await request(app, {
provider: 'instagram',
email: 'test@example.com',
nickname: 'user\r\nX-Injected: evil',
extra: 'line1\nline2',
});
expect(status).toBe(200);
expect(body.status).toBe('ok');
});
});