feat: add ability to force email verification (#1837)

This commit is contained in:
Elias Schneider
2026-10-13 22:04:33 +02:00
committed by GitHub
parent eb8ea6bed2
commit 0ffeb170cf
81 changed files with 2310 additions and 263 deletions
@@ -250,6 +250,8 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('Email Login Code Requested by User').click();
await page.getByLabel('Email Login Code from Admin').click();
await page.getByLabel('API Key Expiration').click();
await page.getByLabel('Email Verification', { exact: true }).click();
await page.getByRole('option', { name: /^Required/ }).click();
await saveUnsavedChanges(page);
@@ -264,6 +266,26 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
await expect(page.getByLabel('Email Verification', { exact: true })).toContainText('Required');
});
test('Required email verification turns off emails verified by default', async ({ page }) => {
await page.getByRole('tab', { name: 'Email' }).click();
const emailsVerifiedByDefault = page.getByLabel('Emails verified by default');
await emailsVerifiedByDefault.click();
await expect(emailsVerifiedByDefault).toBeChecked();
await page.getByLabel('Email Verification', { exact: true }).click();
await page.getByRole('option', { name: /^Required/ }).click();
await expect(emailsVerifiedByDefault).not.toBeChecked();
await expect(emailsVerifiedByDefault).toBeDisabled();
await page.getByLabel('Email Verification', { exact: true }).click();
await page.getByRole('option', { name: /^Optional/ }).click();
await expect(emailsVerifiedByDefault).toBeEnabled();
});
test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => {
+190
View File
@@ -0,0 +1,190 @@
import base, { expect, type APIRequestContext } from '@playwright/test';
import { emailVerificationTokens, oidcClients, users } from '../data';
import { setAppConfigValues } from '../utils/app-config.util';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import { startSmtpSink, verificationLinkPath, type SmtpSink } from '../utils/smtp.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
const smtpSinkPort = 18125;
const test = base.extend<{ smtp: SmtpSink }>({
smtp: async ({}, use) => {
const sink = await startSmtpSink(smtpSinkPort);
try {
await use(sink);
} finally {
await sink.close();
}
}
});
// Requires email verification and points Pocket ID at the SMTP sink, so the verification links can be followed
async function requireEmailVerificationWithSmtp(request: APIRequestContext) {
await setAppConfigValues(request, {
emailVerification: 'required',
smtpHost: 'host.docker.internal',
smtpPort: String(smtpSinkPort),
smtpFrom: 'pocket-id@test.com',
smtpTls: 'none'
});
}
// Builds the query of an authorization code request for the Nextcloud client
function nextcloudAuthorizeParams() {
const oidcClient = oidcClients.nextcloud;
return new URLSearchParams({
client_id: oidcClient.id,
response_type: 'code',
scope: 'openid profile email',
redirect_uri: oidcClient.callbackUrl,
state: 'nXx-6Qr-owc1SHBa',
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
});
}
// Marks Craig's email as verified through the admin API
async function markCraigVerified(request: APIRequestContext) {
const craig = await request.get(`/api/users/${users.craig.id}`).then((r) => r.json());
const response = await request.put(`/api/users/${users.craig.id}`, {
data: { ...craig, emailVerified: true }
});
expect(response.ok()).toBe(true);
}
test.describe('Required email verification', () => {
test.beforeEach(async ({ page }) => {
await cleanupBackend();
await requireEmailVerificationWithSmtp(page.request);
// Craig is a regular user whose email is not verified yet
await authUtil.changeUser(page, 'craig');
});
test('Unverified user can only reach their account', async ({ page }) => {
await page.goto('/settings/apps');
await page.waitForURL('/settings/account');
await expect(page.getByText('Verify your email address to use your account')).toBeVisible();
await expect(page.getByRole('link', { name: 'My Apps' })).toHaveCount(0);
const response = await page.request.get('/api/oidc/users/me/clients');
expect(response.status()).toBe(403);
});
test('Unverified user cannot sign in to a client', async ({ page }) => {
await page.goto(`/authorize?${nextcloudAuthorizeParams().toString()}`);
await expect(page).toHaveURL(/\/interaction\/error\?error=/);
await expect(
page.getByRole('paragraph').filter({
hasText: 'You must verify your email address before you can access this service.'
})
).toBeVisible();
await page.getByRole('link', { name: 'Verify email address' }).click();
await page.waitForURL('/settings/account');
});
test('Unverified user is stopped right after signing in during authorization', async ({
page
}) => {
// Sign out so the sign-in happens on the interaction page, with the passkey the setup already registered for Craig
await page.context().clearCookies();
await page.goto(`/authorize?${nextcloudAuthorizeParams().toString()}`);
await page.getByRole('button', { name: 'Sign in' }).click();
// Completing the sign-in step already requires a verified email, so the consent screen is never shown
await expect(
page.getByText('You must verify your email address before you can sign in to applications.')
).toBeVisible();
await expect(page.getByTestId('scopes')).toHaveCount(0);
await page.getByRole('link', { name: 'Verify email address' }).click();
await page.waitForURL('/settings/account');
});
test('Verifying the email restores full access', async ({ page }) => {
const token = emailVerificationTokens.find((t) => !t.expired)!.token;
await page.goto(`/verify-email?token=${token}`);
await page.waitForURL('/settings/account?emailVerificationState=success');
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/apps');
await expect(page.getByRole('link', { name: 'My Apps' })).toBeVisible();
});
});
test.describe('Email change with required verification', () => {
test.beforeEach(async ({ page }) => {
await cleanupBackend();
await requireEmailVerificationWithSmtp(page.request);
await markCraigVerified(page.request);
await authUtil.changeUser(page, 'craig');
});
test('A new email takes effect once it is confirmed', async ({ page, smtp }) => {
const newEmail = 'craig.new@test.com';
await page.goto('/settings/account');
await page.getByLabel('Email').fill(newEmail);
await saveUnsavedChanges(page);
// The verified email stays in effect and the account keeps full access until the change is confirmed
await expect(page.getByText('Confirm your new email address')).toBeVisible();
await expect(page.getByLabel('Email')).toHaveValue(users.craig.email);
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/apps');
await page.goto(verificationLinkPath(smtp, newEmail));
await page.waitForURL('/settings/account?emailVerificationState=success');
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
await expect(page.getByText('Confirm your new email address')).toHaveCount(0);
});
test('A canceled email change can no longer be confirmed', async ({ page, smtp }) => {
const newEmail = 'craig.canceled@test.com';
await page.goto('/settings/account');
await page.getByLabel('Email').fill(newEmail);
await saveUnsavedChanges(page);
await page.getByRole('button', { name: 'Cancel change' }).click();
await expect(page.getByText('Email change canceled.')).toBeVisible();
await expect(page.getByText('Confirm your new email address')).toHaveCount(0);
await page.goto(verificationLinkPath(smtp, newEmail));
await page.waitForURL(
'/settings/account?emailVerificationState=Email+verification+token+is+invalid'
);
await expect(page.getByLabel('Email')).toHaveValue(users.craig.email);
});
});
test('Signing up sends a verification link that unlocks the account', async ({ page, smtp }) => {
await cleanupBackend();
await requireEmailVerificationWithSmtp(page.request);
await setAppConfigValues(page.request, { allowUserSignups: 'open' });
await page.context().clearCookies();
const email = 'verified.signup@test.com';
await page.goto('/signup');
await page.getByLabel('First name').fill('Verified');
await page.getByLabel('Last name').fill('Signup');
await page.getByLabel('Username').fill('verifiedsignup');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await page.getByRole('button', { name: 'Skip for now' }).click();
await page
.getByRole('alertdialog', { name: 'Skip Passkey Setup' })
.getByRole('button', { name: 'Skip for now' })
.click();
await page.waitForURL('/settings/account');
await page.goto(verificationLinkPath(smtp, email));
await page.waitForURL('/settings/account?emailVerificationState=success');
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/apps');
});
+34
View File
@@ -1,5 +1,6 @@
import test, { expect, type Page } from '@playwright/test';
import { signupTokens, userGroups, users } from '../data';
import { setAppConfigValues } from '../utils/app-config.util';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
@@ -296,6 +297,39 @@ test.describe('User Signup', () => {
await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
});
test('Open signup with required email verification restricts the new account', async ({
page
}) => {
// Required verification needs an SMTP server, but nothing listens on it, so the email sent at signup just fails
await setAppConfigValues(page.request, {
emailVerification: 'required',
smtpHost: 'localhost',
smtpFrom: 'pocket-id@test.com'
});
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByLabel('First name').fill('Pending');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('pendinguser');
await page.getByLabel('Email').fill('pending.user@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await page.getByRole('button', { name: 'Skip for now' }).click();
await page
.getByRole('alertdialog', { name: 'Skip Passkey Setup' })
.getByRole('button', { name: 'Skip for now' })
.click();
await page.waitForURL('/settings/account');
await expect(page.getByText('Verify your email address to use your account')).toBeVisible();
await expect(page.getByText('your account will be deleted')).toBeVisible();
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/account');
});
test('Token usage limit is enforced', async ({ page }) => {
await setSignupMode(page, 'Signup with token');