Files
Vamsi Karthik R c3056cc720 test(alerts): E2E suite for email destinations and distribution lists (#13876)
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.
2026-08-20 04:19:42 +00:00

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');
});