diff --git a/e2e/config.ts b/e2e/config.ts index a9dfbe521..e22bdb88d 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -44,6 +44,8 @@ export const routes = { overview: '/admin/overview', settings: '/admin/settings', devices: '/admin/devices', + groups: '/admin/groups', + webhooks: '/admin/webhooks' }, authorize: '/api/v1/oauth/authorize', }; diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index 59026a199..2c8e607a2 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -8,6 +8,7 @@ import { createUser } from '../utils/controllers/createUser'; import { loginBasic, loginRecoveryCodes, loginTOTP } from '../utils/controllers/login'; import { logout } from '../utils/controllers/logout'; import { enableEmailMFA } from '../utils/controllers/mfa/enableEmail'; +import { enableSecurityKey } from '../utils/controllers/mfa/enableSecurityKey'; import { enableTOTP } from '../utils/controllers/mfa/enableTOTP'; import { changePassword, changePasswordByAdmin } from '../utils/controllers/profile'; import { disableUser } from '../utils/controllers/toggleUserState'; @@ -30,7 +31,7 @@ test.describe('Test user authentication', () => { expect(page.url()).toBe(routes.base + routes.admin.wizard); }); - test('Create user and login as him', async ({ page, browser }) => { + test('Create user and log in as him', async ({ page, browser }) => { await waitForBase(page); await createUser(browser, testUser); await loginBasic(page, testUser); @@ -38,7 +39,7 @@ test.describe('Test user authentication', () => { expect(page.url()).toBe(routes.base + routes.me); }); - test('Login with admin user TOTP', async ({ page, browser }) => { + test('Log in with admin user TOTP', async ({ page, browser }) => { await waitForBase(page); await loginBasic(page, defaultUserAdmin); const { secret } = await enableTOTP(browser, defaultUserAdmin); @@ -48,7 +49,7 @@ test.describe('Test user authentication', () => { await waitForRoute(page, routes.admin.wizard); }); - test('Login with user TOTP', async ({ page, browser }) => { + test('Log in with user TOTP', async ({ page, browser }) => { await waitForBase(page); await createUser(browser, testUser); const { secret } = await enableTOTP(browser, testUser); @@ -69,7 +70,7 @@ test.describe('Test user authentication', () => { expect(page.url()).toBe(routes.base + routes.me); }); - test('Login with Email TOTP', async ({ page, browser }) => { + test('Log in with Email TOTP', async ({ page, browser }) => { await waitForBase(page); await createUser(browser, testUser); const { secret } = await enableEmailMFA(browser, testUser); @@ -84,7 +85,7 @@ test.describe('Test user authentication', () => { await waitForRoute(page, routes.me); }); - test('Login as disabled user', async ({ page, browser }) => { + test('Log in as disabled user', async ({ page, browser }) => { await waitForBase(page); await createUser(browser, testUser); await disableUser(browser, testUser); @@ -110,6 +111,28 @@ test.describe('Test user authentication', () => { await page.locator('a[href="/me"]').click(); await responsePromise; }); + test('Disable user MFA and log in', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + await enableTOTP(browser, testUser); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.users, { + waitUntil: 'networkidle', + }); + await page.getByTestId('user-2').locator('.user-edit-cell').click(); + await page.getByTestId('disable-mfa-button').click(); + await page.waitForTimeout(800); + await page.getByRole('button', { name: 'Disable MFA' }).click(); + await page.waitForTimeout(800); + await page.goto(routes.base + routes.admin.users + `/${testUser.username}`, { + waitUntil: 'networkidle', + }); + await expect(page.locator('.mfa .status .message')).toHaveText('Disabled'); + await logout(page); + await loginBasic(page, testUser); + await page.waitForTimeout(800); + await expect(page.locator('.mfa .status .message')).toHaveText('Disabled'); + }); }); test.describe('Test password change', () => { @@ -145,3 +168,103 @@ test.describe('Test password change', () => { expect(page.url()).toBe(routes.base + routes.me); }); }); + +test.describe('Test security keys', () => { + let testUser: User; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Create user and log in with security key', async ({ page, browser, context }) => { + await waitForBase(page); + await createUser(browser, testUser); + const { credentialId, rpId, privateKey, userHandle } = await enableSecurityKey( + browser, + testUser, + 'key_name', + ); + await page.goto(routes.base); + await waitForRoute(page, routes.auth.login); + await page.getByTestId('login-form-username').fill(testUser.username); + await page.getByTestId('login-form-password').fill(testUser.password); + await page.getByTestId('login-form-submit').click(); + await page.waitForTimeout(1000); + + const authenticator = await context.newCDPSession(page); + await authenticator.send('WebAuthn.enable'); + const { authenticatorId: loginAuthenticatorId } = await authenticator.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'usb', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }, + ); + + await authenticator.send('WebAuthn.addCredential', { + authenticatorId: loginAuthenticatorId, + credential: { + credentialId, + isResidentCredential: true, + rpId, + privateKey, + userHandle, + signCount: 1, + }, + }); + await page.getByTestId('use-security-key').click(); + await page.waitForTimeout(2000); + await expect(page.url()).toBe(routes.base + routes.me); + }); + + test('Add security key to admin and log in', async ({ page, browser, context }) => { + await waitForBase(page); + const { credentialId, rpId, privateKey, userHandle } = await enableSecurityKey( + browser, + defaultUserAdmin, + 'key_name', + ); + await page.goto(routes.base); + await waitForRoute(page, routes.auth.login); + await page.getByTestId('login-form-username').fill(defaultUserAdmin.username); + await page.getByTestId('login-form-password').fill(defaultUserAdmin.password); + await page.getByTestId('login-form-submit').click(); + await page.waitForTimeout(1000); + + const authenticator = await context.newCDPSession(page); + await authenticator.send('WebAuthn.enable'); + const { authenticatorId: loginAuthenticatorId } = await authenticator.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'usb', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }, + ); + + await authenticator.send('WebAuthn.addCredential', { + authenticatorId: loginAuthenticatorId, + credential: { + credentialId, + isResidentCredential: true, + rpId, + privateKey, + userHandle, + signCount: 1, + }, + }); + await page.getByTestId('use-security-key').click(); + await page.waitForTimeout(2000); + await expect(page.url()).toBe(routes.base + routes.admin.wizard); + }); +}); diff --git a/e2e/tests/groups.spec.ts b/e2e/tests/groups.spec.ts index 4cbb2acf5..d2826997b 100644 --- a/e2e/tests/groups.spec.ts +++ b/e2e/tests/groups.spec.ts @@ -1,10 +1,12 @@ import { expect, test } from '@playwright/test'; -import { routes, testUserTemplate } from '../config'; +import { defaultUserAdmin, routes, testUserTemplate } from '../config'; import { createUser } from '../utils/controllers/createUser'; +import { createGroup } from '../utils/controllers/groups'; import { loginBasic } from '../utils/controllers/login'; import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; +import { waitForPromise } from '../utils/waitForPromise'; import { waitForRoute } from '../utils/waitForRoute'; test.describe('Test groups', () => { @@ -18,4 +20,44 @@ test.describe('Test groups', () => { await waitForRoute(page, routes.admin.wizard); expect(page.url()).toBe(routes.base + routes.admin.wizard); }); + + test('Bulk assign groups', async ({ page, browser }) => { + const additionalUsers = [ + { ...testUserTemplate, mail: 'test2@test.com', username: 'test2' }, + { ...testUserTemplate, mail: 'test3@test.com', username: 'test3' }, + ]; + const test_group_name = 'test_group'; + await waitForBase(page); + const testUser1 = { ...testUserTemplate, mail: 'test1@test.com', username: 'test1' }; + await createUser(browser, testUser1, ['Admin']); + + for (const newuser of additionalUsers) { + await createUser(browser, newuser); + } + await createGroup(browser, true, test_group_name); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.users, { + waitUntil: 'networkidle', + }); + + const userCheckboxes = page.locator('[data-testid^="user-"]').locator('.select-cell'); + const checkboxCount = await page.locator('[data-testid^="user-"]').count(); + for (let i = 0; i < checkboxCount; i++) { + await userCheckboxes.nth(i).click(); + } + await page.getByTestId('group-bulk-assign').click(); + await page.locator('.groups-container').waitFor({ state: 'visible', timeout: 10000 }); + await waitForPromise(2000); + await page.locator('.select-row').nth(2).click(); + await page.getByTestId('confirm-bulk-assign').click(); + await page.locator('.groups-container').waitFor({ state: 'hidden', timeout: 10000 }); + const testGroupElements = page.locator( + `.groups-cell .group .text-container:has-text("${test_group_name}")`, + ); + + // 2(default admin + user with admin group) + additional users + await expect(testGroupElements).toHaveCount(2 + additionalUsers.length, { + timeout: 5000, + }); + }); }); diff --git a/e2e/tests/webhook.spec.ts b/e2e/tests/webhook.spec.ts new file mode 100644 index 000000000..23ec939d5 --- /dev/null +++ b/e2e/tests/webhook.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from '@playwright/test'; + +import { defaultUserAdmin, routes } from '../config'; +import { loginBasic } from '../utils/controllers/login'; +import { createWebhook } from '../utils/controllers/webhooks'; +import { dockerRestart } from '../utils/docker'; + +test.describe('Test webhooks', () => { + test.beforeEach(() => { + dockerRestart(); + }); + const webhook_url = 'https://defguard.defguard/webhook'; + const webhook_description = 'example webhook'; + const webhook_secret = 'secret'; + + test('Create webhook and verify content', async ({ page, browser }) => { + await createWebhook(browser, webhook_url, webhook_description, webhook_secret); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.webhooks, { + waitUntil: 'networkidle', + }); + await page.waitForTimeout(2000); + + const webhookRow = page.locator('.default-row'); + + const webhook_url_cell = await webhookRow.locator('.cell-0 span').textContent(); + expect(webhook_url_cell).toBe(webhook_url); + + const webhook_description_cell = await webhookRow + .locator('.cell-1 span') + .textContent(); + expect(webhook_description_cell).toBe(webhook_description); + + const webhook_state_cell = await webhookRow.locator('.cell-2 span').textContent(); + expect(webhook_state_cell).toBe('Enabled'); + + const editButton = webhookRow.locator('.cell-3 .edit-button'); + await expect(editButton).toBeVisible(); + }); + + const new_webhook_url = 'https://changed.defguard/webhook'; + const new_webhook_description = 'changed webhook'; + test('Create, modify webhook and verify content', async ({ page, browser }) => { + await createWebhook(browser, webhook_url, webhook_description, 'secret'); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.webhooks, { + waitUntil: 'networkidle', + }); + await page.waitForTimeout(2000); + + const webhookRow = page.locator('.default-row'); + + const webhook_url_cell = await webhookRow.locator('.cell-0 span').textContent(); + expect(webhook_url_cell).toBe(webhook_url); + + const webhook_description_cell = await webhookRow + .locator('.cell-1 span') + .textContent(); + expect(webhook_description_cell).toBe(webhook_description); + + const webhook_state_cell = await webhookRow.locator('.cell-2 span').textContent(); + expect(webhook_state_cell).toBe('Enabled'); + + const editButton = webhookRow.locator('.cell-3 .edit-button'); + await expect(editButton).toBeVisible(); + // check if webhook is OK + // then edit webhook + await webhookRow.locator('.cell-3 .edit-button').click(); + + await page + .locator('.edit-button-floating-ui') + .getByRole('button', { name: 'Edit' }) + .click(); + + await page.getByTestId('field-url').fill(new_webhook_url); + await page.getByTestId('field-description').fill(new_webhook_description); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForTimeout(1000); + + const changed_webhookRow = page.locator('.default-row'); + const changed_webhook_url_cell = await changed_webhookRow + .locator('.cell-0 span') + .textContent(); + expect(changed_webhook_url_cell).toBe(new_webhook_url); + + const changed_webhook_description_cell = await changed_webhookRow + .locator('.cell-1 span') + .textContent(); + expect(changed_webhook_description_cell).toBe(new_webhook_description); + + const changed_webhook_state_cell = await changed_webhookRow + .locator('.cell-2 span') + .textContent(); + expect(changed_webhook_state_cell).toBe('Enabled'); + }); + + test('Create webhook, change state and verify content', async ({ page, browser }) => { + await createWebhook(browser, webhook_url, webhook_description, webhook_secret); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.webhooks, { + waitUntil: 'networkidle', + }); + await page.waitForTimeout(2000); + const webhookRow = page.locator('.default-row'); + await webhookRow.locator('.cell-3 .edit-button').click(); + await page + .locator('.edit-button-floating-ui') + .getByRole('button', { name: 'Disable' }) + .click(); + await page.waitForTimeout(2000); + + // is everything ok after changing state to Disabled? + const webhook_url_cell = await webhookRow.locator('.cell-0 span').textContent(); + expect(webhook_url_cell).toBe(webhook_url); + + const webhook_description_cell = await webhookRow + .locator('.cell-1 span') + .textContent(); + expect(webhook_description_cell).toBe(webhook_description); + + const webhook_state_cell = await webhookRow.locator('.cell-2 span').textContent(); + expect(webhook_state_cell).toBe('Disabled'); + + const editButton = webhookRow.locator('.cell-3 .edit-button'); + await expect(editButton).toBeVisible(); + + await webhookRow.locator('.cell-3 .edit-button').click(); + await page + .locator('.edit-button-floating-ui') + .getByRole('button', { name: 'Enable' }) + .click(); + await page.waitForTimeout(2000); + + // is everything ok after changing state to Enabled? + const changed_webhook_url_cell = await webhookRow + .locator('.cell-0 span') + .textContent(); + expect(changed_webhook_url_cell).toBe(webhook_url); + + const changed_webhook_description_cell = await webhookRow + .locator('.cell-1 span') + .textContent(); + expect(changed_webhook_description_cell).toBe(webhook_description); + + const changed_webhook_state_cell = await webhookRow + .locator('.cell-2 span') + .textContent(); + expect(changed_webhook_state_cell).toBe('Enabled'); + + const changed_editButton = webhookRow.locator('.cell-3 .edit-button'); + await expect(changed_editButton).toBeVisible(); + }); + test('Create webhook and delete it', async ({ page, browser }) => { + await createWebhook(browser, webhook_url, webhook_description, webhook_secret); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.webhooks, { + waitUntil: 'networkidle', + }); + await page.waitForTimeout(2000); + + const webhookRow = page.locator('.default-row'); + const editButton = webhookRow.locator('.cell-3 .edit-button'); + await expect(editButton).toBeVisible(); + // check if webhook is OK + // then edit webhook + await webhookRow.locator('.cell-3 .edit-button').click(); + await page + .locator('.edit-button-floating-ui') + .getByRole('button', { name: 'Delete webhook' }) + .click(); + await page.locator('.modal-content').waitFor({ state: 'visible', timeout: 5000 }); + await page.getByRole('button', { name: 'Delete' }).click(); + await page.waitForTimeout(2000); + const webhookRows = page.locator('.default-row'); + await expect(webhookRows).toHaveCount(0); + }); +}); diff --git a/e2e/utils/controllers/groups.ts b/e2e/utils/controllers/groups.ts new file mode 100644 index 000000000..e1b5dbe56 --- /dev/null +++ b/e2e/utils/controllers/groups.ts @@ -0,0 +1,21 @@ +import { Browser } from 'playwright'; + +import { defaultUserAdmin, routes } from '../../config'; +import { waitForBase } from '../waitForBase'; +import { loginBasic } from './login'; + +export const createGroup = async ( + browser: Browser, + is_admin: boolean, + group_name: string, +): Promise => { + const context = await browser.newContext(); + const page = await context.newPage(); + await waitForBase(page); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.groups); + await page.getByTestId('add-group').click(); + await page.getByTestId('field-name').fill(group_name); + await page.getByTestId('submit-group').click(); + await context.close(); +}; diff --git a/e2e/utils/controllers/mfa/enableSecurityKey.ts b/e2e/utils/controllers/mfa/enableSecurityKey.ts new file mode 100644 index 000000000..41910bd4f --- /dev/null +++ b/e2e/utils/controllers/mfa/enableSecurityKey.ts @@ -0,0 +1,68 @@ +import { Browser } from 'playwright'; + +import { routes } from '../../../config'; +import { User } from '../../../types'; +import { waitForBase } from '../../waitForBase'; +import { waitForRoute } from '../../waitForRoute'; +import { loginBasic } from '../login'; + +export type EnableSecurityKeyResult = { + credentialId: string; + rpId?: string; + privateKey: string; + userHandle?: string; +}; + +export const enableSecurityKey = async ( + browser: Browser, + user: User, + keyName: string, +): Promise => { + const context = await browser.newContext(); + const page = await context.newPage(); + await waitForBase(page); + await loginBasic(page, user); + + const url = routes.base + routes.me; + await page.goto(url); + await waitForRoute(page, url); + + await page.getByTestId('edit-user').click(); + await page.getByTestId('edit-security-key').click(); + await page.getByTestId('edit-security-key').click(); // triggering this twice because this button works this way + await page.waitForTimeout(1000); + await page.getByTestId('manage-security-keys').click(); + await page.waitForTimeout(1000); + + await page.getByTestId('field-name').fill(keyName); + await page.getByTestId('add-new-security-key').click(); + + const authenticator = await context.newCDPSession(page); + await authenticator.send('WebAuthn.enable'); + + const { authenticatorId } = await authenticator.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'usb', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }, + ); + await page.waitForTimeout(2000); + await page.getByTestId('accept-recovery').click(); + const { credentials } = await authenticator.send('WebAuthn.getCredentials', { + authenticatorId, + }); + const credential = credentials[0]; + await context.close(); + return { + credentialId: credential.credentialId, + rpId: credential.rpId, + privateKey: credential.privateKey, + userHandle: credential.userHandle, + }; +}; diff --git a/e2e/utils/controllers/webhooks.ts b/e2e/utils/controllers/webhooks.ts new file mode 100644 index 000000000..d8e1d0ee1 --- /dev/null +++ b/e2e/utils/controllers/webhooks.ts @@ -0,0 +1,33 @@ +import { Browser } from 'playwright'; + +import { defaultUserAdmin, routes } from '../../config'; +import { waitForBase } from '../waitForBase'; +import { loginBasic } from './login'; + +export const createWebhook = async ( + browser: Browser, + url: string, + description: string, + secret_token?: string, +): Promise => { + const context = await browser.newContext(); + const page = await context.newPage(); + await waitForBase(page); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.admin.webhooks); + await page.getByRole('button', { name: 'Add new' }).click(); + await page.waitForTimeout(800); + await page.getByTestId('field-url').fill(url); + await page.getByTestId('field-description').fill(description); + if (secret_token) { + await page.getByTestId('field-token').fill(secret_token); + } else { + await page.getByTestId('field-token').fill(' '); + } + await page.getByTestId('field-on_user_created').click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForTimeout(2000); + + await context.close(); +}; diff --git a/web/src/pages/auth/MFARoute/MFAWebAuthN/MFAWebAuthN.tsx b/web/src/pages/auth/MFARoute/MFAWebAuthN/MFAWebAuthN.tsx index fd1ced43e..0676d01a7 100644 --- a/web/src/pages/auth/MFARoute/MFAWebAuthN/MFAWebAuthN.tsx +++ b/web/src/pages/auth/MFARoute/MFAWebAuthN/MFAWebAuthN.tsx @@ -73,6 +73,7 @@ export const MFAWebAuthN = () => { onClick={() => mfaStart()} size={ButtonSize.LARGE} styleVariant={ButtonStyleVariant.PRIMARY} + data-testid="use-security-key" /> ); diff --git a/web/src/pages/groups/components/GroupsManagement/GroupsManagement.tsx b/web/src/pages/groups/components/GroupsManagement/GroupsManagement.tsx index dfbb86a76..5f561b4ba 100644 --- a/web/src/pages/groups/components/GroupsManagement/GroupsManagement.tsx +++ b/web/src/pages/groups/components/GroupsManagement/GroupsManagement.tsx @@ -48,6 +48,7 @@ export const GroupsManagement = () => { icon={} text="Add new" onClick={() => openGroupModal()} + data-testid="add-group" /> {data && } diff --git a/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx b/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx index 2dc07e45d..a2fd5c0c6 100644 --- a/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx +++ b/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx @@ -210,6 +210,7 @@ const ModalContent = () => { text={localLL.submit()} styleVariant={ButtonStyleVariant.PRIMARY} type="submit" + data-testid="submit-group" /> diff --git a/web/src/pages/users/UserProfile/UserAuthInfo/UserAuthInfoMFA.tsx b/web/src/pages/users/UserProfile/UserAuthInfo/UserAuthInfoMFA.tsx index 53bb1f9e7..c8665cd15 100644 --- a/web/src/pages/users/UserProfile/UserAuthInfo/UserAuthInfoMFA.tsx +++ b/web/src/pages/users/UserProfile/UserAuthInfo/UserAuthInfoMFA.tsx @@ -263,8 +263,9 @@ export const UserAuthInfoMFA = () => {

{LL.userPage.userAuthInfo.mfa.labels.webauth()}

{getWebAuthNInfoText} - + setModalsState({ diff --git a/web/src/pages/users/UserProfile/UserAuthInfo/modals/ManageWebAuthNModal/components/RegisterWebAuthNForm.tsx b/web/src/pages/users/UserProfile/UserAuthInfo/modals/ManageWebAuthNModal/components/RegisterWebAuthNForm.tsx index f631c5aaf..391973870 100644 --- a/web/src/pages/users/UserProfile/UserAuthInfo/modals/ManageWebAuthNModal/components/RegisterWebAuthNForm.tsx +++ b/web/src/pages/users/UserProfile/UserAuthInfo/modals/ManageWebAuthNModal/components/RegisterWebAuthNForm.tsx @@ -111,6 +111,7 @@ export const RegisterWebAuthNForm = () => { onClick={() => setModalState({ manageWebAuthNKeysModal: { visible: false } })} />
diff --git a/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx b/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx index 79d40db00..9abd0a841 100644 --- a/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx +++ b/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx @@ -100,6 +100,7 @@ export const VersionUpdateToast = ({ id }: ToastOptions) => { {LL.modals.updatesNotificationToaster.controls.more()}