feat(testing): add vitest suite + guerrilla mail email pool #1
7 changed files with 343 additions and 10 deletions
|
|
@ -5,15 +5,23 @@
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node app.js",
|
"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": {
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"helmet": "^7.1.0",
|
|
||||||
"express-rate-limit": "^7.3.1",
|
"express-rate-limit": "^7.3.1",
|
||||||
"nodemailer": "^6.9.13",
|
|
||||||
"googleapis": "^144.0.0",
|
"googleapis": "^144.0.0",
|
||||||
"dotenv": "^16.4.5"
|
"helmet": "^7.1.0",
|
||||||
|
"nodemailer": "^6.9.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
api/scripts/fetch-test-emails.mjs
Normal file
56
api/scripts/fetch-test-emails.mjs
Normal file
|
|
@ -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); });
|
||||||
10
api/scripts/test-emails.txt
Normal file
10
api/scripts/test-emails.txt
Normal file
|
|
@ -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
|
||||||
|
|
@ -2,11 +2,16 @@
|
||||||
|
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const { execSync } = require('child_process');
|
||||||
sendmail: true,
|
|
||||||
newline: 'unix',
|
function hasSendmail() {
|
||||||
path: '/usr/sbin/sendmail'
|
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
|
DATOS DE PROVEEDORES
|
||||||
|
|
@ -108,6 +113,7 @@ exports.sendErasureMail = async ({ provider, email, nickname, phone, address, ex
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: 'privacy@resetea.net',
|
from: 'privacy@resetea.net',
|
||||||
|
replyTo: email,
|
||||||
to: providerInfo.email,
|
to: providerInfo.email,
|
||||||
subject: `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`,
|
subject: `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`,
|
||||||
text: letterText,
|
text: letterText,
|
||||||
|
|
|
||||||
80
api/tests/__snapshots__/erase.test.js.snap
Normal file
80
api/tests/__snapshots__/erase.test.js.snap
Normal 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
159
api/tests/erase.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
api/vitest.config.js
Normal file
14
api/vitest.config.js
Normal file
|
|
@ -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\//],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue