Compare commits

...

35 Commits

Author SHA1 Message Date
Aswin V 3028a09bc0
Merge 59a5572c07 into 5000983a36 2024-05-02 12:19:00 +05:30
Aswin V 59a5572c07 Refactor 2024-05-02 12:18:52 +05:30
Aswin V 495b0f3c7b Merge branch 'main' into 2600-extend-e2e 2024-05-02 11:22:06 +05:30
Aswin V 04731b2c66 Test case for wrong redirect url 2024-04-30 17:55:37 +05:30
Aswin V 279563bf16 Fixture method to update SSO connection 2024-04-30 17:55:16 +05:30
Aswin V b19677db83 Merge branch 'main' into 2600-extend-e2e 2024-04-30 15:04:22 +05:30
Aswin V 6834414b33
Merge branch 'main' into 2600-extend-e2e 2024-04-25 14:17:56 +05:30
Aswin V aadd5cb96e Spec for OAuth2 wrapper + 1 SAML and 1 OIDC providers 2024-04-25 12:29:53 +05:30
Aswin V 765cf208ac Make client id secret dynamic 2024-04-24 22:14:00 +05:30
Aswin V 3b38728ddd Comment tweak 2024-04-24 22:10:31 +05:30
Aswin V 6c1ca53a10 Add OIDC porvider tests 2024-04-24 17:41:29 +05:30
Aswin V 028dbc8d5e Make fixture generic for OIDC SSO 2024-04-24 17:41:09 +05:30
Aswin V 5cccdc56ff Use portal fixture for common utils 2024-04-24 16:17:55 +05:30
Aswin V 9ae1968a7d Merge branch 'main' into 2600-extend-e2e 2024-04-24 15:51:32 +05:30
Aswin V b889f18427 Fix sso naming 2024-04-24 15:47:50 +05:30
Aswin V d6923b30aa Keep track of connections inside fixture, delete all method fix 2024-04-24 15:47:30 +05:30
Aswin V 951dc31b69 Remove only 2024-04-24 14:54:54 +05:30
Aswin V ed00a1e15d Rename test file 2024-04-24 14:17:58 +05:30
Aswin V cffe44899b Fix locator 2024-04-24 14:16:15 +05:30
Aswin V 4e436479b2 WIP fixture tweak and add more test cases 2024-04-24 14:15:54 +05:30
Aswin V 313e72894e Fixture WIP 2024-04-24 13:47:11 +05:30
Aswin V 86af98d79a Try fixture 2024-04-24 00:30:53 +05:30
Aswin V 294f2f8145 Enable stdout for webserver 2024-04-23 16:06:36 +05:30
Aswin V 661ef1dc50 Align folder layout with sidebar features 2024-04-23 16:02:18 +05:30
Aswin V a20c789fce
Merge branch 'main' into 2600-extend-e2e 2024-04-23 16:01:02 +05:30
Aswin V 80ce96d0b1 Disable multiple workers for now 2024-04-23 11:44:58 +05:30
Aswin V 76707c66ba Merge branch 'main' into 2600-extend-e2e 2024-04-23 09:34:04 +05:30
Aswin V 7fab37a20d Increase number of workers for playwright execution 2024-04-23 00:09:12 +05:30
Aswin V 0fd40a4f17 Update mocksaml docker 2024-04-23 00:02:08 +05:30
Aswin V bd1fe1e295 Cleanup debugging changes 2024-04-23 00:01:46 +05:30
Aswin V fca1cbde7b Debug failing test 2024-04-22 23:50:56 +05:30
Aswin V 5e2cf048af Remove only 2024-04-19 00:13:54 +05:30
Aswin V 9cb20c8c7c Add SAML SSO connection 2024-04-19 00:06:45 +05:30
Aswin V 5077cb14ce Start adding tests for main sections under Admin UI 2024-04-18 22:32:45 +05:30
Aswin V c6374df4fc Not needed with standalone build in CI as well as local runs 2024-04-18 22:32:04 +05:30
10 changed files with 385 additions and 4 deletions

View File

@ -111,7 +111,7 @@ jobs:
ports:
- '8000:8000'
mocksaml:
image: boxyhq/mock-saml:1.2.0
image: boxyhq/mock-saml:1.3.9
ports:
- 4000:4000
env:

View File

@ -0,0 +1,2 @@
export { Portal } from './portal';
export { SSOPage } from './sso-page';

View File

@ -0,0 +1,12 @@
import { Locator, Page, expect } from '@playwright/test';
export class Portal {
userAvatarLocator: Locator;
constructor(public readonly page: Page) {
this.userAvatarLocator = this.page.getByTestId('user-avatar');
}
async isLoggedIn() {
// assert login state
await expect(this.userAvatarLocator).toBeVisible();
}
}

View File

@ -0,0 +1,159 @@
import type { Page, Locator } from '@playwright/test';
import { adminPortalSSODefaults } from '@lib/env';
const ADMIN_PORTAL_TENANT = adminPortalSSODefaults.tenant;
const ADMIN_PORTAL_PRODUCT = adminPortalSSODefaults.product;
const MOCKSAML_ORIGIN = process.env.MOCKSAML_ORIGIN || 'https://mocksaml.com';
const MOCKSAML_SIGNIN_BUTTON_NAME = 'Sign In';
const MOCKLAB_ORIGIN = 'https://oauth.wiremockapi.cloud';
const MOCKLAB_CLIENT_ID = 'mocklab_oauth2';
const MOCKLAB_CLIENT_SECRET = 'mocklab_secret';
const MOCKLAB_SIGNIN_BUTTON_NAME = 'Login';
const MOCKLAB_DISCOVERY_ENDPOINT = 'https://oauth.wiremockapi.cloud/.well-known/openid-configuration';
export class SSOPage {
private readonly createConnection: Locator;
private readonly nameInput: Locator;
private readonly tenantInput: Locator;
private readonly productInput: Locator;
private readonly redirectURLSInput: Locator;
private readonly defaultRedirectURLInput: Locator;
private readonly metadataUrlInput: Locator;
private readonly oidcDiscoveryUrlInput: Locator;
private readonly oidcClientIdInput: Locator;
private readonly oidcClientSecretInput: Locator;
private readonly saveConnection: Locator;
private readonly deleteButton: Locator;
private readonly confirmButton: Locator;
private connections: string[];
constructor(public readonly page: Page) {
this.connections = [];
this.createConnection = this.page.getByTestId('create-connection');
this.nameInput = this.page.getByLabel('Connection name (Optional)');
this.tenantInput = this.page.getByLabel('Tenant');
this.productInput = this.page.getByLabel('Product');
this.redirectURLSInput = page
.getByRole('group')
.filter({ hasText: 'Allowed redirect URLs' })
.locator(page.getByRole('textbox').first());
this.defaultRedirectURLInput = this.page.getByLabel('Default redirect URL');
this.metadataUrlInput = this.page.getByLabel('Metadata URL');
this.oidcDiscoveryUrlInput = this.page.getByLabel('Well-known URL of OpenID Provider');
this.oidcClientIdInput = this.page.getByLabel('Client ID');
this.oidcClientSecretInput = this.page.getByLabel('Client Secret');
this.saveConnection = this.page.getByRole('button', { name: /save/i });
this.deleteButton = this.page.getByRole('button', { name: 'Delete' });
this.confirmButton = this.page.getByRole('button', { name: 'Confirm' });
}
async goto() {
const url = new URL(this.page.url());
if (url.pathname !== '/admin/sso-connection') {
await this.page.goto('/admin/sso-connection');
}
}
async addSSOConnection({
name,
type = 'saml',
baseURL,
}: {
name: string;
type: 'saml' | 'oidc';
baseURL: string;
}) {
const connectionIndex = this.connections.length + 1;
const ssoName = `${name}-${connectionIndex}`;
// Find the new connection button and click on it
await this.createConnection.click();
if (type === 'oidc') {
// Toggle connection type to OIDC
await this.page.getByLabel('OIDC').check();
}
// Fill the name for the connection
await this.nameInput.fill(ssoName);
// Fill the tenant for the connection
await this.tenantInput.fill(ADMIN_PORTAL_TENANT);
// Fill the product for the connection
await this.productInput.fill(ADMIN_PORTAL_PRODUCT);
// Fill the Allowed redirect URLs for the connection
await this.redirectURLSInput.fill(baseURL!);
// Fill the default redirect URLs for the connection
await this.defaultRedirectURLInput.fill(`${baseURL}/admin/auth/idp-login`);
if (type === 'saml') {
// Enter the metadata url for mocksaml in the form
await this.metadataUrlInput.fill(`${MOCKSAML_ORIGIN}/api/namespace/${ssoName}/saml/metadata`);
}
if (type === 'oidc') {
// Enter the OIDC client credentials for mocklab in the form
await this.oidcClientIdInput.fill(`${MOCKLAB_CLIENT_ID}-${connectionIndex}`);
await this.oidcClientSecretInput.fill(`${MOCKLAB_CLIENT_SECRET}-${connectionIndex}`);
// Enter the OIDC discovery url for mocklab in the form
await this.oidcDiscoveryUrlInput.fill(MOCKLAB_DISCOVERY_ENDPOINT);
}
// submit the form
await this.saveConnection.click();
this.connections = [...this.connections, ssoName];
}
async gotoEditView(name: string) {
await this.goto();
const editButton = this.page.getByText(name).locator('xpath=..').getByLabel('Edit');
await editButton.click();
}
async updateSSOConnection({ name, url }: { name: string; url: string }) {
await this.gotoEditView(name);
await this.redirectURLSInput.fill(url);
await this.saveConnection.click();
}
async deleteSSOConnection(name: string) {
await this.gotoEditView(name);
// click the delete and confirm deletion
await this.deleteButton.click();
await this.confirmButton.click();
}
async deleteAllSSOConnections() {
let _connection;
while ((_connection = this.connections.shift())) {
await this.deleteSSOConnection(_connection);
}
}
async logout() {
const userAvatarLocator = this.page.getByTestId('user-avatar');
// Logout from the magic link authentication
await userAvatarLocator.click();
await this.page.getByTestId('logout').click();
}
async signInWithSSO() {
await this.page.getByTestId('sso-login-button').click();
}
async selectIdP(name: string) {
const idpSelectionTitle = 'Select an Identity Provider to continue';
await this.page.getByText(idpSelectionTitle).waitFor();
await this.page.getByRole('button', { name }).click();
}
async signInWithMockSAML() {
// Perform sign in at mocksaml
await this.page.waitForURL((url) => url.origin === MOCKSAML_ORIGIN);
await this.page.getByPlaceholder('jackson').fill('bob');
await this.page.getByRole('button', { name: MOCKSAML_SIGNIN_BUTTON_NAME }).click();
}
async signInWithMockLab() {
// Perform sign in at mocklab
await this.page.waitForURL((url) => url.origin === MOCKLAB_ORIGIN);
await this.page.getByPlaceholder('yours@example.com').fill('bob@oidc.com');
await this.page.getByRole('button', { name: MOCKLAB_SIGNIN_BUTTON_NAME }).click();
}
}

View File

@ -0,0 +1,32 @@
import { test as baseTest, expect } from '@playwright/test';
import { SSOPage } from 'e2e/support/fixtures';
type MyFixtures = {
ssoPage: SSOPage;
};
export const test = baseTest.extend<MyFixtures>({
ssoPage: async ({ page, baseURL }, use, testInfo) => {
const ssoPage = new SSOPage(page);
const ssoName = `saml-${testInfo.workerIndex}`;
await ssoPage.goto();
await ssoPage.addSSOConnection({ name: ssoName, type: 'saml', baseURL: baseURL! });
await use(ssoPage);
},
});
test('OAuth2 wrapper + SAML provider + wrong redirectUrl', async ({ ssoPage, page, baseURL }, testInfo) => {
// check if the first added connection appears in the connection list
await expect(page.getByText(`saml-${testInfo.workerIndex}-1`)).toBeVisible();
await ssoPage.updateSSOConnection({
name: `saml-${testInfo.workerIndex}-1`,
url: 'https://invalid-url.com',
});
// Logout of magic link login
await ssoPage.logout();
await ssoPage.signInWithSSO();
// Wait for browser to redirect to error page
await page.waitForURL((url) => url.origin === baseURL && url.pathname === '/error');
// Assert error text
await expect(page.getByText(`SSO error: Redirect URL is not allowed.`)).toBeVisible();
});

View File

@ -0,0 +1,57 @@
import { test as baseTest, expect } from '@playwright/test';
import { Portal, SSOPage } from 'e2e/support/fixtures';
type MyFixtures = {
ssoPage: SSOPage;
portal: Portal;
};
export const test = baseTest.extend<MyFixtures>({
ssoPage: async ({ page, baseURL }, use, testInfo) => {
const ssoPage = new SSOPage(page);
const ssoName = `oidc-${testInfo.workerIndex}`;
await ssoPage.goto();
await ssoPage.addSSOConnection({ name: ssoName, type: 'oidc', baseURL: baseURL! });
await use(ssoPage);
await ssoPage.deleteAllSSOConnections();
},
portal: async ({ page }, use) => {
const portal = new Portal(page);
await use(portal);
},
});
test('OAuth2 wrapper + OIDC provider', async ({ ssoPage, portal, page, baseURL }, testInfo) => {
// check if the first added connection appears in the connection list
await expect(page.getByText(`oidc-${testInfo.workerIndex}-1`)).toBeVisible();
// Logout of magic link login
await ssoPage.logout();
await ssoPage.signInWithSSO();
// Login using MockLab
await ssoPage.signInWithMockLab();
// Wait for browser to redirect back to admin portal
await page.waitForURL((url) => url.origin === baseURL);
// Assert logged in state
await portal.isLoggedIn();
});
test('OAuth2 wrapper + 2 OIDC providers', async ({ ssoPage, portal, page, baseURL }, testInfo) => {
const ssoName = `oidc-${testInfo.workerIndex}`;
// check if the first added connection appears in the connection list
await expect(page.getByText(`${ssoName}-1`)).toBeVisible();
// Add second OIDC connection
await ssoPage.addSSOConnection({ name: ssoName, type: 'oidc', baseURL: baseURL! });
// check if the second added connection appears in the connection list
await expect(page.getByText(`${ssoName}-2`)).toBeVisible();
// Logout of magic link login
await ssoPage.logout();
// Login using MockLab
await ssoPage.signInWithSSO();
// Select IdP from selection screen
await ssoPage.selectIdP(`${ssoName}-2`);
await ssoPage.signInWithMockLab();
// Wait for browser to redirect back to admin portal
await page.waitForURL((url) => url.origin === baseURL);
// Assert logged in state
await portal.isLoggedIn();
});

View File

@ -0,0 +1,62 @@
import { test as baseTest, expect } from '@playwright/test';
import { Portal, SSOPage } from 'e2e/support/fixtures';
type MyFixtures = {
ssoPage: SSOPage;
portal: Portal;
};
export const test = baseTest.extend<MyFixtures>({
ssoPage: async ({ page, baseURL }, use, testInfo) => {
const ssoPage = new SSOPage(page);
let ssoName = `saml-${testInfo.workerIndex}`;
await ssoPage.goto();
await ssoPage.addSSOConnection({ name: ssoName, type: 'saml', baseURL: baseURL! });
await ssoPage.goto();
ssoName = `oidc-${testInfo.workerIndex}`;
await ssoPage.addSSOConnection({ name: ssoName, type: 'oidc', baseURL: baseURL! });
await use(ssoPage);
await ssoPage.deleteAllSSOConnections();
},
portal: async ({ page }, use) => {
const portal = new Portal(page);
await use(portal);
},
});
test('OAuth2 wrapper + SAML provider + OIDC provider', async ({
ssoPage,
portal,
page,
baseURL,
}, testInfo) => {
// check if the first added connection appears in the connection list
await expect(page.getByText(`saml-${testInfo.workerIndex}-1`)).toBeVisible();
// check if the second added connection appears in the connection list
await expect(page.getByText(`oidc-${testInfo.workerIndex}-2`)).toBeVisible();
// Logout of magic link login
await ssoPage.logout();
// Login using MockLab
await ssoPage.signInWithSSO();
// Select IdP from selection screen
await ssoPage.selectIdP(`saml-${testInfo.workerIndex}-1`);
// Login using MockSAML
await ssoPage.signInWithMockSAML();
// Wait for browser to redirect back to admin portal
await page.waitForURL((url) => url.origin === baseURL);
// Assert logged in state
await portal.isLoggedIn();
// Logout of SAML login
await ssoPage.logout();
// Login using MockLab
await ssoPage.signInWithSSO();
// Select IdP from selection screen
await ssoPage.selectIdP(`oidc-${testInfo.workerIndex}-2`);
// Login using MockLab
await ssoPage.signInWithMockLab();
// Wait for browser to redirect back to admin portal
await page.waitForURL((url) => url.origin === baseURL);
// Assert logged in state
await portal.isLoggedIn();
});

View File

@ -0,0 +1,57 @@
import { test as baseTest, expect } from '@playwright/test';
import { Portal, SSOPage } from 'e2e/support/fixtures';
type MyFixtures = {
ssoPage: SSOPage;
portal: Portal;
};
export const test = baseTest.extend<MyFixtures>({
ssoPage: async ({ page, baseURL }, use, testInfo) => {
const ssoPage = new SSOPage(page);
const ssoName = `saml-${testInfo.workerIndex}`;
await ssoPage.goto();
await ssoPage.addSSOConnection({ name: ssoName, type: 'saml', baseURL: baseURL! });
await use(ssoPage);
await ssoPage.deleteAllSSOConnections();
},
portal: async ({ page }, use) => {
const portal = new Portal(page);
await use(portal);
},
});
test('OAuth2 wrapper + SAML provider', async ({ ssoPage, portal, page, baseURL }, testInfo) => {
// check if the first added connection appears in the connection list
await expect(page.getByText(`saml-${testInfo.workerIndex}-1`)).toBeVisible();
// Logout of magic link login
await ssoPage.logout();
await ssoPage.signInWithSSO();
// Login using MockSAML
await ssoPage.signInWithMockSAML();
// Wait for browser to redirect back to admin portal
await page.waitForURL((url) => url.origin === baseURL);
// Assert logged in state
await portal.isLoggedIn();
});
test('OAuth2 wrapper + 2 SAML providers', async ({ ssoPage, portal, page, baseURL }, testInfo) => {
const ssoName = `saml-${testInfo.workerIndex}`;
// check if the first added connection appears in the connection list
await expect(page.getByText(`${ssoName}-1`)).toBeVisible();
// Add second SAML connection
await ssoPage.addSSOConnection({ name: ssoName, type: 'saml', baseURL: baseURL! });
// check if the second added connection appears in the connection list
await expect(page.getByText(`${ssoName}-2`)).toBeVisible();
// Logout of magic link login
await ssoPage.logout();
// Login using MockSAML
await ssoPage.signInWithSSO();
// Select IdP from selection screen
await ssoPage.selectIdP(`${ssoName}-2`);
await ssoPage.signInWithMockSAML();
// Wait for browser to redirect back to admin portal
await page.waitForURL((url) => url.origin === baseURL);
// Assert logged in state
await portal.isLoggedIn();
});

View File

@ -75,7 +75,7 @@ test.describe('Admin Portal SSO - SAML', () => {
test('delete the SAML SSO connection', async ({ page }) => {
await page.goto('/admin/settings');
// select the row of the connection list table, then locate the edit button
const editButton = page.getByText(TEST_SAML_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
const editButton = page.getByText(TEST_SAML_SSO_CONNECTION_NAME).locator('xpath=..').getByLabel('Edit');
await editButton.click();
// click the delete and confirm deletion
await page.getByRole('button', { name: 'Delete' }).click();
@ -138,7 +138,7 @@ test.describe('Admin Portal SSO - OIDC', () => {
await page.getByTestId('logout').click();
// Click on login with sso button
await page.getByTestId('sso-login-button').click();
// Perform sign in at mocksaml
// Perform sign in at mocklab
await page.waitForURL((url) => url.origin === MOCKLAB_ORIGIN);
await page.getByPlaceholder('yours@example.com').fill('bob@oidc.com');
await page.getByRole('button', { name: MOCKLAB_SIGNIN_BUTTON_NAME }).click();

View File

@ -22,7 +22,7 @@ const config: PlaywrightTestConfig = {
timeout: 60 * 1000,
reuseExistingServer: !process.env.CI,
env: {
NODE_ENV: 'test',
DEBUG: 'pw:webserver',
},
},