diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index cd1ad0b8..4e65064d 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -94,7 +94,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { }, Username: "craig", Email: utils.Ptr("craig.federighi@test.com"), - EmailVerified: true, + EmailVerified: false, FirstName: "Craig", LastName: "Federighi", DisplayName: "Craig Federighi", @@ -429,6 +429,31 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } + emailVerificationTokens := []model.EmailVerificationToken{ + { + Base: model.Base{ + ID: "ef9ca469-b178-4857-bd39-26639dca45de", + }, + Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup", + ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)), + UserID: users[1].ID, + }, + { + Base: model.Base{ + ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00", + }, + Token: "EXPIRED1234567890ABCDE", + ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)), + UserID: users[1].ID, + }, + } + + for _, token := range emailVerificationTokens { + if err := tx.Create(&token).Error; err != nil { + return err + } + } + keyValues := []model.KV{ { Key: jwkutils.PrivateKeyDBKey, diff --git a/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql b/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql index 66e1222d..c9342103 100644 --- a/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql +++ b/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql @@ -4,11 +4,7 @@ CREATE TABLE email_verification_tokens created_at TIMESTAMPTZ NOT NULL, token TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, - user_id TEXT NOT NULL, - CONSTRAINT email_verification_tokens_user_id_fkey - FOREIGN KEY (user_id) - REFERENCES users (id) - ON DELETE CASCADE + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE ); ALTER TABLE users diff --git a/tests/data.ts b/tests/data.ts index c47af384..8ad813d1 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -92,6 +92,11 @@ export const oneTimeAccessTokens = [ { token: 'YCGDtftvsvYWiXd0', expired: true } ]; +export const emailVerificationTokens = [ + { token: '2FZFSoupBdHyqIL65bWTsgCgHIhxlXup', expired: false }, + { token: 'EXPIRED1234567890ABCDE', expired: true } +]; + export const apiKeys = [ { id: '5f1fa856-c164-4295-961e-175a0d22d725', diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 1a90feb3..66a67e1d 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,6 +1,6 @@ { "provider": "sqlite", - "version": 20260106140900, + "version": 20260109090200, "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"], "tables": { "api_keys": [ @@ -316,7 +316,7 @@ "disabled": false, "display_name": "Craig Federighi", "email": "craig.federighi@test.com", - "email_verified": true, + "email_verified": false, "first_name": "Craig", "id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", "is_admin": false, @@ -376,6 +376,22 @@ "id": "267f6907-7bc8-4ea1-9d47-c42a172dc1c7", "user_verification": "preferred" } + ], + "email_verification_tokens": [ + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-26T12:39:02Z", + "id": "ef9ca469-b178-4857-bd39-26639dca45de", + "token": "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup", + "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + }, + { + "created_at": "2025-11-24T12:39:02Z", + "expires_at": "2025-11-25T12:39:02Z", + "id": "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00", + "token": "EXPIRED1234567890ABCDE", + "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + } ] } } diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index 0e3a8171..52348c3c 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -1,5 +1,5 @@ import test, { expect } from '@playwright/test'; -import { users } from '../data'; +import { emailVerificationTokens, users } from '../data'; import authUtil from '../utils/auth.util'; import { cleanupBackend } from '../utils/cleanup.util'; import passkeyUtil from '../utils/passkey.util'; @@ -128,3 +128,31 @@ test('Generate own one time access token as non admin', async ({ page, context } await page.goto(link!); await page.waitForURL('/settings/account'); }); + +test('Email verification succeeds', async ({ page, context }) => { + await context.clearCookies(); + + const token = emailVerificationTokens.find((t) => !t.expired)!.token; + await page.goto(`/verify-email?token=${token}`); + await (await passkeyUtil.init(page)).addPasskey('craig'); + + await page.getByRole('button', { name: 'Authenticate' }).click(); + await page.waitForURL('/settings/account?emailVerificationState=success'); + + await expect(page.getByText('Email Verified Successfully')).toBeVisible(); +}); + +test('Email verification fails with expired token', async ({ page, context }) => { + await context.clearCookies(); + + const token = emailVerificationTokens.find((t) => t.expired)!.token; + await page.goto(`/verify-email?token=${token}`); + await (await passkeyUtil.init(page)).addPasskey('craig'); + + await page.getByRole('button', { name: 'Authenticate' }).click(); + await page.waitForURL( + '/settings/account?emailVerificationState=Invalid+email+verification+token' + ); + + await expect(page.getByText('Invalid email verification token')).toBeVisible(); +}); diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index 9636dc7c..9f16d8fc 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -113,7 +113,7 @@ test('End session without id token hint shows confirmation page', async ({ page await expect(page).toHaveURL('/logout'); await page.getByRole('button', { name: 'Sign out' }).click(); - await expect(page).toHaveURL('/login?redirect=%2F"'); + await expect(page).toHaveURL('/login?redirect=%2F'); }); test('End session with id token hint redirects to callback URL', async ({ page }) => {