From 15faa5b1361e7228d201897f73a69dcda9c1403e Mon Sep 17 00:00:00 2001 From: robacheque Date: Fri, 17 Apr 2026 22:47:06 +0200 Subject: [PATCH] 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 --- api/package.json | 18 ++- api/scripts/fetch-test-emails.mjs | 56 ++++++++ api/scripts/test-emails.txt | 10 ++ api/services/mailer.js | 16 ++- api/tests/__snapshots__/erase.test.js.snap | 80 +++++++++++ api/tests/erase.test.js | 159 +++++++++++++++++++++ api/vitest.config.js | 14 ++ 7 files changed, 343 insertions(+), 10 deletions(-) create mode 100644 api/scripts/fetch-test-emails.mjs create mode 100644 api/scripts/test-emails.txt create mode 100644 api/tests/__snapshots__/erase.test.js.snap create mode 100644 api/tests/erase.test.js create mode 100644 api/vitest.config.js diff --git a/api/package.json b/api/package.json index b59c455..c0210a9 100644 --- a/api/package.json +++ b/api/package.json @@ -5,15 +5,23 @@ "main": "app.js", "scripts": { "start": "node app.js", - "dev": "node --watch app.js" + "dev": "node --watch app.js", + "test": "vitest run", + "test:watch": "vitest", + "emails:fetch": "node scripts/fetch-test-emails.mjs" + }, + "engines": { + "node": ">=18" }, - "engines": { "node": ">=18" }, "dependencies": { + "dotenv": "^16.4.5", "express": "^4.19.2", - "helmet": "^7.1.0", "express-rate-limit": "^7.3.1", - "nodemailer": "^6.9.13", "googleapis": "^144.0.0", - "dotenv": "^16.4.5" + "helmet": "^7.1.0", + "nodemailer": "^6.9.13" + }, + "devDependencies": { + "vitest": "^4.1.4" } } diff --git a/api/scripts/fetch-test-emails.mjs b/api/scripts/fetch-test-emails.mjs new file mode 100644 index 0000000..d21e2bd --- /dev/null +++ b/api/scripts/fetch-test-emails.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Fetches a pool of disposable email addresses from Guerrilla Mail API + * and writes them to scripts/test-emails.txt (one per line). + * + * Usage: node scripts/fetch-test-emails.mjs [count] + * Default count: 10 + */ + +import { writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const COUNT = parseInt(process.argv[2] ?? '10', 10); +const OUT = join(dirname(fileURLToPath(import.meta.url)), 'test-emails.txt'); +const API = 'http://api.guerrillamail.com/ajax.php'; + +async function fetchEmail() { + const url = `${API}?f=get_email_address&ip=127.0.0.1&agent=resetea-test-script`; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (!data.email_addr) throw new Error(`Unexpected response: ${JSON.stringify(data)}`); + return data.email_addr; +} + +async function main() { + const emails = new Set(); + let attempts = 0; + const maxAttempts = COUNT * 3; + + process.stdout.write(`Fetching ${COUNT} addresses`); + + while (emails.size < COUNT && attempts < maxAttempts) { + attempts++; + try { + const addr = await fetchEmail(); + if (!emails.has(addr)) { + emails.add(addr); + process.stdout.write('.'); + } + // Small delay to avoid hammering the API + await new Promise(r => setTimeout(r, 300)); + } catch (e) { + process.stderr.write(`\n[warn] ${e.message}`); + } + } + + process.stdout.write('\n'); + + const lines = [...emails].join('\n') + '\n'; + writeFileSync(OUT, lines, 'utf8'); + console.log(`Saved ${emails.size} addresses → ${OUT}`); +} + +main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/api/scripts/test-emails.txt b/api/scripts/test-emails.txt new file mode 100644 index 0000000..7f59c46 --- /dev/null +++ b/api/scripts/test-emails.txt @@ -0,0 +1,10 @@ +ldvrpmba@guerrillamailblock.com +cdxoatax@guerrillamailblock.com +ourtmekp@guerrillamailblock.com +tjjrgggw@guerrillamailblock.com +uahmunlk@guerrillamailblock.com +qgwuzgmz@guerrillamailblock.com +haicjqga@guerrillamailblock.com +lqynosqe@guerrillamailblock.com +iqhahoij@guerrillamailblock.com +mgxbbfig@guerrillamailblock.com diff --git a/api/services/mailer.js b/api/services/mailer.js index 22baf80..b5d28fc 100644 --- a/api/services/mailer.js +++ b/api/services/mailer.js @@ -2,11 +2,16 @@ const nodemailer = require('nodemailer'); -const transporter = nodemailer.createTransport({ - sendmail: true, - newline: 'unix', - path: '/usr/sbin/sendmail' -}); +const { execSync } = require('child_process'); + +function hasSendmail() { + try { execSync('ls /usr/sbin/sendmail', { stdio: 'ignore' }); return true; } + catch { return false; } +} + +const transporter = hasSendmail() + ? nodemailer.createTransport({ sendmail: true, newline: 'unix', path: '/usr/sbin/sendmail' }) + : nodemailer.createTransport({ direct: true }); /* ============================================================ DATOS DE PROVEEDORES @@ -108,6 +113,7 @@ exports.sendErasureMail = async ({ provider, email, nickname, phone, address, ex await transporter.sendMail({ from: 'privacy@resetea.net', + replyTo: email, to: providerInfo.email, subject: `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`, text: letterText, diff --git a/api/tests/__snapshots__/erase.test.js.snap b/api/tests/__snapshots__/erase.test.js.snap new file mode 100644 index 0000000..07dc6b5 --- /dev/null +++ b/api/tests/__snapshots__/erase.test.js.snap @@ -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)", + }, +} +`; diff --git a/api/tests/erase.test.js b/api/tests/erase.test.js new file mode 100644 index 0000000..3d1f58d --- /dev/null +++ b/api/tests/erase.test.js @@ -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'); + }); +}); diff --git a/api/vitest.config.js b/api/vitest.config.js new file mode 100644 index 0000000..bfcc029 --- /dev/null +++ b/api/vitest.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + server: { + deps: { + // Force Vite to transform local source files (not just node_modules) + // so vi.mock() can intercept require() calls inside CJS route handlers + inline: [/routes\//, /services\//], + }, + }, + }, +}); -- 2.39.5