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:
155
tests/e2e/realapi/portal-imap-activation.spec.ts
Normal file
155
tests/e2e/realapi/portal-imap-activation.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user