test(portal): IMAP full-lifecycle activation E2E + dev probe helper

New realapi spec walks the entire portal activation loop over real
network: invite via the admin endpoint → wait for the activation email
to land in the IMAP mailbox → extract the token from the body link →
activate the portal user via the public API → sign in with the new
password.

The match logic deliberately doesn't filter on the TO header — the
combination of EMAIL_REDIRECT_TO rewriting and +addressing made TO
matching brittle. Instead we discriminate by sender (noreply@…),
subject keyword, and body link pattern, which is unique enough to find
exactly the email this test triggered.

Companion script scripts/dev-imap-probe.ts dumps the most recent ~10
messages with from/to/subject/date — useful for debugging when an IMAP
match goes wrong.

Skips when IMAP_HOST / IMAP_USER / IMAP_PASS are absent so the suite
stays portable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-27 15:40:28 +02:00
parent 4a859245b7
commit 65b241805e
2 changed files with 221 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
import 'dotenv/config';
import { test, expect } from '@playwright/test';
import { ImapFlow } from 'imapflow';
import { simpleParser, type ParsedMail } from 'mailparser';
import { login, apiHeaders } from '../smoke/helpers';
/**
* Real-API end-to-end test for the portal activation flow over real SMTP+IMAP.
*
* Walks the full loop:
* 1. Create a fresh client with a +addressed test email.
* 2. Admin invites the client to the portal, triggering the activation email.
* 3. Connect to IMAP and poll until the activation email lands.
* 4. Parse the email, extract the activation token from the link.
* 5. POST /api/portal/auth/activate with the token + new password.
* 6. POST /api/portal/auth/sign-in with the same email + password.
* 7. Assert the sign-in succeeds.
*
* Requires IMAP_HOST + IMAP_USER + IMAP_PASS in the environment to read the
* destination mailbox. The test sends to <IMAP_USER>+imap-test-<ts> which
* routes back to the same inbox via standard +addressing.
*
* NOTE: this test bypasses EMAIL_REDIRECT_TO by sending to the IMAP user
* directly. If EMAIL_REDIRECT_TO is set, the redirect still applies — but
* the redirect target is also IMAP_USER in our dev setup, so it works out.
*/
const IMAP_HOST = process.env.IMAP_HOST;
const IMAP_PORT = Number(process.env.IMAP_PORT ?? 993);
const IMAP_USER = process.env.IMAP_USER;
const IMAP_PASS = process.env.IMAP_PASS;
const MAILBOX = 'INBOX';
const POLL_TIMEOUT_MS = 60_000;
const POLL_INTERVAL_MS = 5_000;
function plusAddress(base: string, tag: string): string {
const [local, domain] = base.split('@');
return `${local}+${tag}@${domain}`;
}
async function fetchActivationToken(args: {
recipientEmail: string;
notBefore: Date;
}): Promise<string> {
if (!IMAP_HOST || !IMAP_USER || !IMAP_PASS) {
throw new Error('IMAP credentials not configured');
}
const client = new ImapFlow({
host: IMAP_HOST,
port: IMAP_PORT,
secure: IMAP_PORT === 993,
auth: { user: IMAP_USER, pass: IMAP_PASS },
logger: false,
});
await client.connect();
try {
const lock = await client.getMailboxLock(MAILBOX);
try {
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
// Walk recent messages; first one whose body carries an
// /portal/activate?token=... link wins. We don't filter on TO header
// because EMAIL_REDIRECT_TO may rewrite it and +addressing may or may
// not be preserved depending on the server config.
const searchResult = await client.search({ since: args.notBefore });
const uids = Array.isArray(searchResult) ? [...searchResult].reverse() : [];
for (const uid of uids) {
const msg = await client.fetchOne(String(uid), { source: true });
if (!msg || !msg.source) continue;
const parsed: ParsedMail = await simpleParser(msg.source);
// Skip anything that isn't our send (sender + subject discriminator).
const fromAddr = parsed.from?.value?.[0]?.address?.toLowerCase() ?? '';
const subject = (parsed.subject ?? '').toLowerCase();
if (!fromAddr.includes('noreply@letsbe.solutions') || !subject.includes('activate')) {
continue;
}
const body = parsed.html || parsed.text || '';
const match = body.match(/\/portal\/activate\?token=([A-Za-z0-9_\-]+)/);
if (match?.[1]) {
return match[1];
}
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error(
`Activation email for ${args.recipientEmail} not found within ${POLL_TIMEOUT_MS}ms`,
);
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
test.describe('Portal activation: SMTP + IMAP round-trip', () => {
test.skip(!IMAP_HOST || !IMAP_USER || !IMAP_PASS, 'IMAP_HOST / IMAP_USER / IMAP_PASS not set');
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
test('full activation loop via real SMTP delivery + IMAP retrieval', async ({ page }) => {
const stamp = Date.now();
const headers = await apiHeaders(page);
const recipientEmail = plusAddress(IMAP_USER!, `imap-test-${stamp}`);
const notBefore = new Date(Date.now() - 60_000); // 1-min skew tolerance
// ─── 1. Create a fresh client ────────────────────────────────────────────
const clientRes = await page.request.post('/api/v1/clients', {
headers,
data: {
fullName: `IMAP Test Client ${stamp}`,
contacts: [{ channel: 'email', value: recipientEmail, isPrimary: true }],
},
});
expect(clientRes.ok(), `client create: ${clientRes.status()}`).toBe(true);
const clientId = (await clientRes.json()).data.id as string;
// ─── 2. Admin invites the client ─────────────────────────────────────────
const inviteRes = await page.request.post(`/api/v1/clients/${clientId}/portal-user`, {
headers,
data: { email: recipientEmail, name: `IMAP Test Client ${stamp}` },
});
expect(inviteRes.ok(), `invite: ${inviteRes.status()} ${await inviteRes.text()}`).toBe(true);
// ─── 3-4. Poll IMAP and extract the activation token ─────────────────────
// Match on the +addressed recipient — that's what's preserved in the TO
// header even after the mailserver delivers it back to IMAP_USER's inbox.
const token = await fetchActivationToken({
recipientEmail: recipientEmail.toLowerCase(),
notBefore,
});
expect(token, 'activation token extracted from email').toBeTruthy();
// ─── 5. Activate the account ─────────────────────────────────────────────
const password = `Imap-Test-${stamp}!`;
const activateRes = await page.request.post('/api/portal/auth/activate', {
data: { token, password },
});
expect(activateRes.ok(), `activate: ${activateRes.status()} ${await activateRes.text()}`).toBe(
true,
);
// ─── 6-7. Sign in with the new credentials ───────────────────────────────
const signInRes = await page.request.post('/api/portal/auth/sign-in', {
data: { email: recipientEmail, password },
});
expect(signInRes.ok(), `sign-in: ${signInRes.status()} ${await signInRes.text()}`).toBe(true);
});
});