mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
Automated coverage for **email alert destinations and distribution lists**, and unblocks the email tests that have been skipped since they were written. **32 cases** — 27 active, 5 skipped regressions tracking open defects (openobserve/o2-enterprise#2471). **The unblocking.** The two skipped tests said *"the test environment does not have email infrastructure set up"*. That over-stated it: creating, editing, validating and deleting an email destination needs SMTP switched **on**, and never reads a mailbox. Only delivery assertions do. The suite is tiered on that: | Tier | Needs | Cases | |---|---|---| | A | SMTP enabled | 24 | | B | a readable sink | 2 | | C | an alias that fans out | 1 | **Tests** - `Alerts/dl-email-destinations.spec.js` (32) — the DL half (alias accepted, addressed, fanned out) and the recipient-list half, where our code actually runs: splitting/trimming/lowercasing, the org-membership gate, edit round-trips, add/remove/delete, both form paradigms (prebuilt free-text vs the custom path's org-user picker), template auto-attach, duplicate names, cancel, type-switching, keyboard, dark mode at 420px. - `utils/mail-sink.js` — pluggable sink: Mailpit, a Mailinator inbox, or a clean skip with a stated reason. - `utils/o2-api.js` — storage is asserted from the API, never the form field: an input can echo what you typed while the stored value differs. - `scripts/dl-relay.js` — SMTP relay that expands one alias into per-member deliveries, so fan-out runs with no external provider. Same pattern as the in-test HTTP receiver in `alerts-content-templates.spec.js`. - `alertDestinationsPage.js` — +17 methods; the spec carries zero raw locators. **Replaces, without losing coverage.** The two skipped tests in `alerts-destinations-prebuilt.spec.js` are removed for a pointer mapping their intent to its new home: create (`D-01`), edit-add (`D-07`), edit-remove (`ML-08`), delete (`UI-12`), format validation (`UI-02`). Three of those did not exist before. That file now has no skips. **CI.** `ZO_SMTP_ENABLED: true` was already set in `playwright.yml` but had no host or port, so it fell back to `localhost:25` where nothing listens — destinations could be **saved** while every **send** failed silently. The workflow now starts Mailpit and a DL relay as static binaries before the app (no Docker-in-Docker on these runners) and points `ZO_SMTP_*` at them. All three tiers run with no secrets and no egress. Verified in the CI topology: **27 passed, 5 skipped**, `D-02` confirmed reaching all three member mailboxes. **Behaviour.** Self-cleaning via the `auto_dest_dl_*` prefix `cleanup.spec.js` already sweeps, plus an API `afterAll`. `mode: 'parallel'` — each test owns its fixtures. **Findings** are filed in openobserve/o2-enterprise#2471, not fixed here. Two P1s: the *"Show matching rows"* toggle is ignored so log rows are always embedded, and a user removed from the org keeps receiving alerts while the destination can no longer be saved. The regression tests for each are in this PR, skipped until the fix lands. **Paired ENT PR: openobserve/o2-enterprise#2472.** ENT's workflow sets `ZO_SMTP_ENABLED: false`, so without it the suite skips all 27 cases there. The shard matrix needs no ENT change — ENT builds it from this repo's `ci_matrix.json` plus its overlay, so the new spec is picked up automatically. Merge the two together: this one alone leaves enterprise with no email coverage.
118 lines
5.2 KiB
JavaScript
118 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Distribution-list relay — test infrastructure for email fan-out.
|
|
*
|
|
* Mailpit is a sink: it accepts mail for any address and stores exactly ONE
|
|
* message. It has no alias table, so it can never demonstrate what a real
|
|
* distribution list does — turn one alias into N deliveries. This sits in front
|
|
* of it and supplies exactly that, so the DL fan-out case can run with no
|
|
* external mail provider, no secrets and no network egress.
|
|
*
|
|
* Same pattern the repo already uses for webhooks, where
|
|
* alerts-content-templates.spec.js runs an in-test HTTP server to capture what
|
|
* each destination actually sent. This is the email equivalent.
|
|
*
|
|
* OpenObserve --:1026--> [this relay] --:1025--> Mailpit
|
|
* expands DL_ADDRESS into DL_MEMBERS
|
|
*
|
|
* It behaves the way a real MTA does for an alias: the RFC822 headers are passed
|
|
* through untouched (an MTA rewrites the envelope, not the To: header), so
|
|
* recipients still see the alias in To: — which is the property D-02 asserts.
|
|
* Each copy is stamped with Delivered-To so the members can be told apart.
|
|
*
|
|
* Any address that is not a configured alias is forwarded unchanged, so every
|
|
* other suite sharing this runner is unaffected.
|
|
*
|
|
* Env: DL_ADDRESS, DL_MEMBERS (comma-separated), DL_RELAY_PORT, MAILPIT_SMTP_PORT
|
|
*/
|
|
const net = require('net');
|
|
|
|
const LISTEN_PORT = Number(process.env.DL_RELAY_PORT || 1026);
|
|
const FORWARD_HOST = process.env.MAILPIT_SMTP_HOST || '127.0.0.1';
|
|
const FORWARD_PORT = Number(process.env.MAILPIT_SMTP_PORT || 1025);
|
|
const DL_ADDRESS = (process.env.DL_ADDRESS || '').toLowerCase();
|
|
const DL_MEMBERS = (process.env.DL_MEMBERS || '')
|
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
|
|
const log = (...a) => console.log('[dl-relay]', ...a);
|
|
|
|
/** Resolve one address to leaf mailboxes; unknown addresses pass through. */
|
|
function expand(addr) {
|
|
const a = addr.toLowerCase().replace(/^<|>$/g, '').trim();
|
|
return DL_ADDRESS && a === DL_ADDRESS && DL_MEMBERS.length ? DL_MEMBERS : [a];
|
|
}
|
|
|
|
/** Minimal SMTP client: hand one message to Mailpit for one recipient. */
|
|
function forward(from, rcpt, data) {
|
|
return new Promise((resolve) => {
|
|
const sock = net.connect(FORWARD_PORT, FORWARD_HOST);
|
|
const steps = [`EHLO dl-relay`, `MAIL FROM:<${from}>`, `RCPT TO:<${rcpt}>`, 'DATA'];
|
|
let i = -1;
|
|
let inData = false;
|
|
let done = false; // the server answers again after the body; ignore it
|
|
sock.setEncoding('utf8');
|
|
sock.on('data', (chunk) => {
|
|
if (done) return;
|
|
// Only act on the final line of a multi-line reply (e.g. "250-..." then "250 ...").
|
|
const last = chunk.trim().split(/\r?\n/).pop() || '';
|
|
if (/^\d{3}-/.test(last)) return;
|
|
if (inData) { done = true; sock.write('QUIT\r\n'); sock.end(); return resolve(true); }
|
|
i += 1;
|
|
if (i < steps.length) return sock.write(steps[i] + '\r\n');
|
|
// server answered 354 to DATA — send the body, dot-stuffed, then terminate
|
|
inData = true;
|
|
const body = data.replace(/\r?\n\./g, '\n..');
|
|
sock.write(`Delivered-To: ${rcpt}\r\n` + body + '\r\n.\r\n');
|
|
});
|
|
sock.on('error', (e) => { if (!done) log('forward failed', rcpt, e.message); resolve(false); });
|
|
sock.setTimeout(15000, () => { if (!done) log('forward timed out', rcpt); sock.destroy(); resolve(false); });
|
|
});
|
|
}
|
|
|
|
const server = net.createServer((sock) => {
|
|
let rcpts = [];
|
|
let from = '';
|
|
let collecting = false;
|
|
let buf = '';
|
|
sock.setEncoding('utf8');
|
|
sock.write('220 dl-relay ESMTP\r\n');
|
|
|
|
sock.on('data', async (chunk) => {
|
|
if (collecting) {
|
|
buf += chunk;
|
|
const end = buf.indexOf('\r\n.\r\n');
|
|
if (end === -1) return;
|
|
const data = buf.slice(0, end);
|
|
collecting = false;
|
|
buf = '';
|
|
|
|
const leaves = [];
|
|
for (const r of rcpts) for (const leaf of expand(r)) if (!leaves.includes(leaf)) leaves.push(leaf);
|
|
log(`envelope ${JSON.stringify(rcpts)} -> ${leaves.length} delivery(ies)`, leaves);
|
|
for (const leaf of leaves) await forward(from, leaf, data);
|
|
|
|
rcpts = [];
|
|
sock.write('250 OK: queued\r\n');
|
|
return;
|
|
}
|
|
|
|
for (const line of chunk.split(/\r?\n/).filter(Boolean)) {
|
|
const up = line.toUpperCase();
|
|
if (up.startsWith('EHLO') || up.startsWith('HELO')) sock.write('250-dl-relay\r\n250 SIZE 26214400\r\n');
|
|
else if (up.startsWith('MAIL FROM')) { from = (line.split(':')[1] || '').replace(/[<>]/g, '').trim(); sock.write('250 OK\r\n'); }
|
|
else if (up.startsWith('RCPT TO')) { rcpts.push((line.split(':')[1] || '').trim()); sock.write('250 OK\r\n'); }
|
|
else if (up === 'DATA') { collecting = true; sock.write('354 End data with <CR><LF>.<CR><LF>\r\n'); }
|
|
else if (up === 'RSET') { rcpts = []; sock.write('250 OK\r\n'); }
|
|
else if (up === 'QUIT') { sock.write('221 Bye\r\n'); sock.end(); }
|
|
else sock.write('250 OK\r\n');
|
|
}
|
|
});
|
|
sock.on('error', () => {});
|
|
});
|
|
|
|
server.listen(LISTEN_PORT, '127.0.0.1', () => { // loopback only: shared runner
|
|
log(`listening on ${LISTEN_PORT}, forwarding to ${FORWARD_HOST}:${FORWARD_PORT}`);
|
|
log(DL_ADDRESS ? `alias ${DL_ADDRESS} -> ${DL_MEMBERS.length} member(s): ${DL_MEMBERS.join(', ')}`
|
|
: 'no DL_ADDRESS configured — pure pass-through');
|
|
});
|