Any authenticated user can invite an e-mail address; the mailed single-use token lets exactly one signup through even while registration is closed. Open (pending, unexpired) invitations count against the new instance setting invitations.maxOpenPerUser (default 5, 0 disables inviting) — plus a 20/day per-user rate limit so a revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256 token hash is stored (auth-tokens pattern); a failed signup (taken username) un-redeems the token so the invitee can retry. Surfaces: invitations section in the user settings (list, invite, revoke, quota line; wide table in a focusable .table-scroll region), signup page reads ?invitation=<token> (preview banner, e-mail prefill, closed-mode gate opens only for a previewed-valid token), admin general card gets the quota field (flat RHF name per #322; VS-NfD marked and hideable). Governance: audit actions invitation.created/revoked/accepted (catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide row, i18n de+en including the invitation mail template. Tests: api e2e-db (mail link, closed-mode single-use signup with un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth matrix), new web e2e pack invitations.spec.ts (full UI loop through Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan waits for the new section. Full api suite (107 files / 607 tests), auth/admin-settings/a11y packs green against a fresh local stack. Closes #332
94 lines
4.4 KiB
TypeScript
94 lines
4.4 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
|
|
import { contextForUser, latestMailFor, tokenFromMail } from './helpers';
|
|
|
|
/**
|
|
* Peer invitations (issue #332), the full loop through the UI: a user
|
|
* invites an address, registration is closed, the invitee registers
|
|
* through the mailed link anyway, verifies, and the inviter sees the
|
|
* invitation accepted. Needs Mailpit like the auth pack.
|
|
*/
|
|
const MAILPIT_URL = process.env.E2E_MAILPIT_URL;
|
|
test.skip(!MAILPIT_URL, 'requires a Mailpit instance (E2E_MAILPIT_URL)');
|
|
|
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
|
|
|
test('invite -> closed registration -> signup through the link -> accepted', async ({
|
|
browser,
|
|
page,
|
|
}) => {
|
|
const stamp = Date.now().toString(36);
|
|
const invitee = `invited-${stamp}@dorfteich.test`;
|
|
|
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
const inviter = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
await admin.request.patch('/api/v1/admin/settings', {
|
|
data: { 'auth.registrationMode': 'closed' },
|
|
});
|
|
try {
|
|
// Invite through the settings UI.
|
|
const settingsPage = await inviter.newPage();
|
|
await settingsPage.goto('/settings');
|
|
const section = settingsPage.locator('.invitations');
|
|
await section.getByLabel(/e-mail/i).fill(invitee);
|
|
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
|
|
await expect(section.locator('.invitations__sent')).toHaveText(/sent|verschickt/i);
|
|
const row = section.locator(`.invitation-row[data-email="${invitee}"]`);
|
|
await expect(row.locator('.invitation-row__status')).toHaveText(/open|offen/i);
|
|
|
|
// Plain signup is closed…
|
|
await page.goto('/signup');
|
|
await expect(page.locator('.form-banner')).toHaveText(/closed|geschlossen/i);
|
|
|
|
// …but the mailed link opens the form, inviter banner and prefill included.
|
|
const mail = await latestMailFor(MAILPIT_URL!, invitee);
|
|
const invitationToken = /invitation=([A-Za-z0-9_-]+)/.exec(mail.text)?.[1];
|
|
expect(invitationToken).toBeTruthy();
|
|
await page.goto(`/signup?invitation=${invitationToken}`);
|
|
await expect(page.locator('.signup-invitation__banner')).toBeVisible();
|
|
await expect(page.getByLabel(/e-mail/i)).toHaveValue(invitee);
|
|
const username = `invited-${stamp}`;
|
|
await page.getByLabel(/username|benutzername/i).fill(username);
|
|
await page.getByLabel(/display name|anzeigename/i).fill('Invited Guest');
|
|
await page.getByLabel(/^password|^passwort/i).fill('ein einladungs passwort 1');
|
|
await page.getByRole('button', { name: /register|registrieren/i }).click();
|
|
await expect(page.getByRole('heading', { name: /inbox|postfach/i })).toBeVisible();
|
|
|
|
// The usual verification still applies (the link proves nothing about
|
|
// the mailbox). Two mails went to this address — poll for the second.
|
|
let verifyToken = '';
|
|
await expect(async () => {
|
|
const verifyMail = await latestMailFor(MAILPIT_URL!, invitee);
|
|
expect(verifyMail.text).toContain('/verify-email');
|
|
verifyToken = tokenFromMail(verifyMail.text);
|
|
}).toPass();
|
|
await page.goto(`/verify-email?token=${verifyToken}`);
|
|
await expect(page.getByRole('heading', { name: /confirmed|bestätigt/i })).toBeVisible();
|
|
|
|
// The inviter sees the acceptance; the used link is dead.
|
|
await settingsPage.reload();
|
|
await expect(
|
|
settingsPage
|
|
.locator(`.invitation-row[data-email="${invitee}"]`)
|
|
.locator('.invitation-row__status'),
|
|
).toHaveText(/accepted|angenommen/i);
|
|
await page.goto(`/signup?invitation=${invitationToken}`);
|
|
await expect(page.locator('.signup-invitation__invalid')).toBeVisible();
|
|
|
|
// Revoke flow through the UI: a second invitation dies by revoke.
|
|
const second = `revoked-${stamp}@dorfteich.test`;
|
|
await section.getByLabel(/e-mail/i).fill(second);
|
|
await section.getByRole('button', { name: /^(invite|einladen)$/i }).click();
|
|
const secondRow = section.locator(`.invitation-row[data-email="${second}"]`);
|
|
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/open|offen/i);
|
|
await secondRow.getByRole('button', { name: /revoke|widerrufen/i }).click();
|
|
await expect(secondRow.locator('.invitation-row__status')).toHaveText(/revoked|widerrufen/i);
|
|
} finally {
|
|
await admin.request.patch('/api/v1/admin/settings', {
|
|
data: { 'auth.registrationMode': 'open' },
|
|
});
|
|
await admin.close();
|
|
await inviter.close();
|
|
}
|
|
});
|