Compare commits

...

14 Commits

Author SHA1 Message Date
Utkarsh Mehta f3dca9255c
Merge dd256bbaea into 9943a06ace 2024-05-06 18:53:58 +05:30
ukrocks007 dd256bbaea lint fixes 2024-05-06 18:53:50 +05:30
ukrocks007 eefba3a921 feat: e2e tests 2024-05-06 18:52:13 +05:30
Aswin V 9943a06ace
Extend e2e (#2601)
* Not needed with standalone build in CI as well as local runs

* Start adding tests for main sections under Admin UI

* Add SAML SSO connection

* Remove only

* Debug failing test

* Cleanup debugging changes

* Update mocksaml docker

* Increase number of workers for playwright execution

* Disable multiple workers for now

* Align folder layout with sidebar features

* Enable stdout for webserver

* Try fixture

* Fixture WIP

* WIP fixture tweak and add more test cases

* Fix locator

* Rename test file

* Remove only

* Keep track of connections inside fixture, delete all method fix

* Fix sso naming

* Use portal fixture for common utils

* Make fixture generic for OIDC SSO

* Add OIDC porvider tests

* Comment tweak

* Make client id secret dynamic

* Spec for OAuth2 wrapper + 1 SAML and 1 OIDC providers

* Fixture method to update SSO connection

* Test case for wrong redirect url

* Refactor

* WIP Wrong redirect url test for OIDC provider plus
setup for toggle connection

* WIP inactive connection test

* Set env for credentials login

* Add credentials login to portal fixture

* Fixes
2024-05-06 12:37:12 +01:00
Deepak Prabhakara f08025cc31 updated deployment 2024-05-06 12:36:58 +01:00
ukrocks007 7e363e8231 lint fixes 2024-05-06 15:26:08 +05:30
ukrocks007 ab52092548 feat: Add getDirectoryByProduct function for retrieving a directory by product 2024-05-06 15:22:25 +05:30
Deepak Prabhakara 63baf12a38 Release 1.23.7 2024-05-06 09:16:09 +01:00
ukrocks007 d4e46ab679 chore: Remove unused import in oauth.ts file 2024-05-03 18:29:08 +05:30
ukrocks007 75fd2ff400 feat: Add OAuth helper functions for authorization 2024-05-03 18:24:56 +05:30
ukrocks007 2ded090cb8 feat: Add test for retrieving empty array of SSO connections by product 2024-05-02 18:32:23 +05:30
ukrocks007 28503e7360 feat: Add getConnectionByProduct function for retrieving SSO connections by product 2024-05-02 18:28:01 +05:30
ukrocks007 3ac932085e chore: Update Authorization header in e2e tests 2024-05-02 17:52:57 +05:30
ukrocks007 b7421dd62d chore: Update test.use options in e2e tests 2024-05-02 17:29:48 +05:30
32 changed files with 992 additions and 70 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:

6
e2e/api/helpers/api.ts Normal file
View File

@ -0,0 +1,6 @@
export const options = {
extraHTTPHeaders: {
Authorization: `Api-Key ${process.env.JACKSON_API_KEYS}`,
'Content-Type': 'application/json',
},
};

View File

@ -1,4 +1,5 @@
import { expect, type APIRequestContext } from '@playwright/test';
import { Directory } from 'npm/src';
const directoryBase = {
tenant: 'api-boxyhq',
@ -25,6 +26,18 @@ export const directoryExpected = {
webhook: { endpoint: 'https://example.com', secret: 'secret' },
};
export const updateDirectory = async (request: APIRequestContext, directory: Directory, data: any) => {
const response = await request.patch(`/api/v1/dsync/${directory.id}`, {
data,
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
const { data: updatedDirectory } = await response.json();
return updatedDirectory;
};
export const createDirectory = async (request: APIRequestContext, payload: typeof directoryPayload) => {
const response = await request.post('/api/v1/dsync', {
data: {
@ -59,6 +72,21 @@ export const getDirectory = async (
return data;
};
export const getDirectoryByProduct = async (request: APIRequestContext, { product }: { product: string }) => {
const response = await request.get('/api/v1/dsync/product', {
params: {
product,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
const { data } = await response.json();
return data;
};
export const deleteDirectory = async (request: APIRequestContext, directoryId: string) => {
const response = await request.delete(`/api/v1/dsync/${directoryId}`);

View File

@ -1,5 +1,5 @@
import { expect, type APIRequestContext } from '@playwright/test';
import type { Directory } from '@boxyhq/saml-jackson';
import type { Directory, Group } from '@boxyhq/saml-jackson';
export const createGroup = async (request: APIRequestContext, directory: Directory, group: any) => {
const response = await request.post(`${directory.scim.path}/Groups`, {
@ -15,6 +15,38 @@ export const createGroup = async (request: APIRequestContext, directory: Directo
return await response.json();
};
export const addGroupMember = async (
request: APIRequestContext,
directory: Directory,
group: Group,
member: string
) => {
const response = await request.patch(`${directory.scim.path}/Groups/${group.id}`, {
data: {
Operations: [
{
action: 'addGroupMember',
op: 'add',
path: 'members',
value: [
{
value: member,
},
],
},
],
},
headers: {
Authorization: `Bearer ${directory.scim.secret}`,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
return await response.json();
};
export const getGroupByDisplayName = async (
request: APIRequestContext,
directory: Directory,
@ -47,3 +79,17 @@ export const getGroupById = async (request: APIRequestContext, directory: Direct
return await response.json();
};
export const getGroupsByDirectoryId = async (request: APIRequestContext, directory: Directory) => {
const response = await request.get(`${directory.scim.path}/Groups`, {
headers: {
Authorization: `Bearer ${directory.scim.secret}`,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
const data = await response.json();
return data.Resources;
};

22
e2e/api/helpers/oauth.ts Normal file
View File

@ -0,0 +1,22 @@
import { type APIRequestContext, expect } from '@playwright/test';
import { OAuthReq } from 'npm/src';
// Make oauth autorize request
export const oauthAuthorize = async (request: APIRequestContext, data: OAuthReq, isFailure = false) => {
try {
const response = await request.post('/api/oauth/authorize', {
data,
});
expect(response.ok()).toBe(true);
if (!isFailure) {
expect(response.status()).toBe(302);
}
} catch (ex: any) {
if (isFailure) {
expect(ex.message).toBeDefined();
} else {
throw ex;
}
}
};

View File

@ -71,6 +71,20 @@ export const getConnection = async (
return await response.json();
};
// Get connections by product
export const getConnectionByProduct = async (request: APIRequestContext, product: string) => {
const response = await request.get('/api/v1/sso/product', {
params: {
product,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
return await response.json();
};
// Delete a connection
export const deleteConnection = async (
request: APIRequestContext,
@ -86,3 +100,60 @@ export const deleteConnection = async (
expect(response.ok()).toBe(true);
expect(response.status()).toBe(204);
};
// get a sso trace by id
export const getSSOTraceById = async (request: APIRequestContext, { id }: { id: string }) => {
const response = await request.get('/api/v1/sso-traces', {
params: {
id,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
return await response.json();
};
// get sso traces by product
export const getSSOTracesByProduct = async (request: APIRequestContext, { product }: { product: string }) => {
const response = await request.get('/api/v1/sso-traces/product', {
params: {
product,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
return await response.json();
};
// Delete sso traces by product
export const deleteSSOTraces = async (request: APIRequestContext, { product }: { product: string }) => {
const response = await request.delete('/api/v1/sso-traces/product', {
params: {
product,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(204);
};
// Count sso traces by product
export const countSSOTracesByProduct = async (
request: APIRequestContext,
{ product }: { product: string }
) => {
const response = await request.get('/api/v1/sso-traces/product/count', {
params: {
product,
},
});
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
return await response.json();
};

View File

@ -4,13 +4,9 @@ import { createUser, getUser } from '../../helpers/users';
import { createGroup, getGroupByDisplayName, getGroupById } from '../../helpers/groups';
import groups from '../../../../npm/test/dsync/data/groups';
import users from '../../../../npm/test/dsync/data/users';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
const { tenant, product } = { ...directoryPayload, tenant: 'api-boxyhq-1' };

View File

@ -2,13 +2,9 @@ import { test, expect } from '@playwright/test';
import users from '../../../../npm/test/dsync/data/users';
import { createDirectory, deleteDirectory, directoryPayload, getDirectory } from '../../helpers/directories';
import { createUser, getUser } from '../../helpers/users';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
const { tenant, product } = { ...directoryPayload, tenant: 'api-boxyhq-2' };

View File

@ -5,14 +5,11 @@ import {
directoryExpected,
directoryPayload,
getDirectory,
getDirectoryByProduct,
} from '../../helpers/directories';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
const { tenant, product } = directoryPayload;
@ -25,6 +22,9 @@ test.beforeAll(async ({ request }) => {
test.afterAll(async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
if (!directory) {
return;
}
await deleteDirectory(request, directory.id);
});
@ -157,3 +157,15 @@ test.describe('PATCH /api/v1/dsync/{directoryId}', () => {
});
});
});
test.describe('GET /api/v1/dsync/product', () => {
test('should be able to get a directory by product', async ({ request }) => {
let directories = await getDirectoryByProduct(request, { product });
expect(directories.length).toBe(1);
await deleteDirectory(request, directories[0].id);
directories = await getDirectoryByProduct(request, { product });
expect(directories.length).toBe(0);
});
});

View File

@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
import {
createDirectory,
deleteDirectory,
directoryPayload,
getDirectory,
updateDirectory,
} from '../../helpers/directories';
import groups from '@boxyhq/saml-jackson/test/dsync/data/groups';
import { addGroupMember, createGroup } from '../../helpers/groups';
import { options } from '../../helpers/api';
test.use(options);
const { tenant, product } = { ...directoryPayload, tenant: 'api-boxyhq-3' };
const memberId = 'member1';
test.beforeAll(async ({ request }) => {
let directory = await createDirectory(request, {
...directoryPayload,
tenant,
});
directory = await updateDirectory(request, directory, {
log_webhook_events: true,
});
const group = await createGroup(request, directory, groups[0]);
await addGroupMember(request, directory, group, memberId);
});
test.afterAll(async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
await deleteDirectory(request, directory.id);
});
test.describe('GET /api/v1/dsync/events', () => {
test('should be able to get list of events from a directory', async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
const response = await request.get(`/api/v1/dsync/events`, {
params: {
tenant,
product,
directoryId: directory.id,
},
});
const { data: events } = await response.json();
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
expect(events.length).toBe(2);
});
});
test.describe('GET /api/v1/dsync/events/:event', () => {
test('should be able to delete all the events from directory', async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
let response = await request.get(`/api/v1/dsync/events`, {
params: {
directoryId: directory.id,
},
});
const { data: events } = await response.json();
response = await request.get(`/api/v1/dsync/events/${events[0].id}`, {
params: {
tenant,
product,
directoryId: directory.id,
},
});
const { data: event } = await response.json();
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
expect(event.status_code).toBe(200);
});
});
test.describe('DELETE /api/v1/dsync/events', () => {
test('should be able to delete all the events from directory', async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
let response = await request.delete(`/api/v1/dsync/events`, {
params: {
directoryId: directory.id,
},
});
response = await request.get(`/api/v1/dsync/events`, {
params: {
tenant,
product,
directoryId: directory.id,
},
});
const { data: events } = await response.json();
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
expect(events.length).toBe(0);
});
});

View File

@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { createDirectory, deleteDirectory, directoryPayload, getDirectory } from '../../helpers/directories';
import groups from '@boxyhq/saml-jackson/test/dsync/data/groups';
import { addGroupMember, createGroup, getGroupsByDirectoryId } from '../../helpers/groups';
import { options } from '../../helpers/api';
test.use(options);
const { tenant, product } = { ...directoryPayload, tenant: 'api-boxyhq-3' };
const memberId = 'member1';
test.beforeAll(async ({ request }) => {
const directory = await createDirectory(request, {
...directoryPayload,
tenant,
});
const group = await createGroup(request, directory, groups[0]);
await addGroupMember(request, directory, group, memberId);
});
test.afterAll(async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
await deleteDirectory(request, directory.id);
});
test.describe('GET /api/v1/dsync/groups/:id/members', () => {
test('should be able to get a group members from a directory', async ({ request }) => {
const [directory] = await getDirectory(request, { tenant, product });
const groups = await getGroupsByDirectoryId(request, directory);
const response = await request.get(`/api/v1/dsync/groups/${groups[0].id}/members`, {
params: {
tenant,
product,
},
});
const { data: directoryMembers } = await response.json();
expect(response.ok()).toBe(true);
expect(response.status()).toBe(200);
expect(directoryMembers.length).toBe(1);
expect(directoryMembers).toMatchObject([
{
user_id: memberId,
},
]);
});
});

View File

@ -2,13 +2,9 @@ import { test, expect } from '@playwright/test';
import { createDirectory, deleteDirectory, directoryPayload, getDirectory } from '../../helpers/directories';
import groups from '@boxyhq/saml-jackson/test/dsync/data/groups';
import { createGroup } from '../../helpers/groups';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
const { tenant, product } = { ...directoryPayload, tenant: 'api-boxyhq-3' };

View File

@ -1,14 +1,10 @@
import { test, expect } from '@playwright/test';
import { options } from '../../helpers/api';
const tenant = 'tenant-1';
const product = 'product-1';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
// POST /api/v1/dsync/setuplinks
test('create the setup link', async ({ request }) => {

View File

@ -2,13 +2,9 @@ import { test, expect } from '@playwright/test';
import users from '../../../../npm/test/dsync/data/users';
import { createDirectory, deleteDirectory, directoryPayload, getDirectory } from '../../helpers/directories';
import { createUser } from '../../helpers/users';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
const { tenant, product } = { ...directoryPayload, tenant: 'api-boxyhq-4' };

View File

@ -1,12 +1,8 @@
import { test, expect } from '@playwright/test';
import { SAMLFederationApp } from '@boxyhq/saml-jackson';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
const expectedApp = {
name: 'Test App',

View File

@ -1,11 +1,7 @@
import { test } from '@playwright/test';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
test.describe('OIDC SSO Connection', () => {
//

View File

@ -6,14 +6,11 @@ import {
getRawMetadata,
newConnection,
expectedConnection,
getConnectionByProduct,
} from '../../helpers/sso';
import { options } from '../../helpers/api';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
test.afterEach(async ({ request }) => {
const { tenant, product } = newConnection;
@ -210,3 +207,23 @@ test.describe('GET /api/v1/sso/exists', () => {
expect(response.status()).toBe(404);
});
});
test.describe('GET /api/v1/sso/product', () => {
const { product } = newConnection;
test('should get empty array for SSO connection by product', async ({ request }) => {
const response = await getConnectionByProduct(request, product);
expect(response).toMatchObject([]);
expect(response.length).toBe(0);
});
test('should be able to get SSO Connections by product', async ({ request }) => {
await createConnection(request, newConnection);
const response = await getConnectionByProduct(request, product);
expect(response).toMatchObject([expectedConnection]);
expect(response.length).toBe(1);
});
});

View File

@ -1,14 +1,10 @@
import { test, expect } from '@playwright/test';
import { options } from '../../helpers/api';
const tenant = 'tenant-1';
const product = 'product-1';
test.use({
extraHTTPHeaders: {
Authorization: `Api-Key secret`,
'Content-Type': 'application/json',
},
});
test.use(options);
// POST /api/v1/sso/setuplinks
test('create the setup link', async ({ request }) => {

View File

@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';
import {
createConnection,
deleteConnection,
newConnection,
deleteSSOTraces,
getSSOTracesByProduct,
getSSOTraceById,
countSSOTracesByProduct,
} from '../../helpers/sso';
import { options } from '../../helpers/api';
import { oauthAuthorize } from '../../helpers/oauth';
test.use(options);
test.afterEach(async ({ request }) => {
const { tenant, product } = newConnection;
// Delete the connection & traces after each test
await deleteConnection(request, { tenant, product });
await deleteSSOTraces(request, { product });
});
test.describe('POST /api/v1/sso', () => {
test('should be able to get empty list of traces', async ({ request }) => {
await createConnection(request, newConnection);
const list = await getSSOTracesByProduct(request, { product: newConnection.product });
expect(list.data.length).toBe(0);
});
test('should be able to get non empty list of traces', async ({ request }) => {
await createConnection(request, newConnection);
await oauthAuthorize(
request,
{
client_id: 'dummy',
tenant: 'dummy',
product: newConnection.product,
state: 'Bb-w_AqDxZh90BBVz4PRhtRIRetOgo0AR0pmrhzyICU',
response_type: 'code',
redirect_uri: newConnection.redirectUrl[0].replaceAll('*', ''),
code_challenge: 'OcMni5eZvSrQ2ev7tPICbcE7q1piL8Abi8IfJtWbUtY',
code_challenge_method: 'S256',
},
true
);
const res = await countSSOTracesByProduct(request, { product: newConnection.product });
expect(res.count).toBeGreaterThan(0);
});
test('should be able to get sso trace by Id', async ({ request }) => {
await createConnection(request, newConnection);
await oauthAuthorize(
request,
{
client_id: 'dummy',
tenant: 'dummy',
product: newConnection.product,
state: 'Bb-w_AqDxZh90BBVz4PRhtRIRetOgo0AR0pmrhzyICU',
response_type: 'code',
redirect_uri: newConnection.redirectUrl[0].replaceAll('*', ''),
code_challenge: 'OcMni5eZvSrQ2ev7tPICbcE7q1piL8Abi8IfJtWbUtY',
code_challenge_method: 'S256',
},
true
);
const list = await getSSOTracesByProduct(request, { product: newConnection.product });
expect(list.data.length).toBe(1);
const trace = await getSSOTraceById(request, { id: list.data[0].traceId });
expect(trace.data).toMatchObject(list.data[0]);
});
test('should be able to delete sso trace by product', async ({ request }) => {
await createConnection(request, newConnection);
await oauthAuthorize(
request,
{
client_id: 'dummy',
tenant: 'dummy',
product: newConnection.product,
state: 'Bb-w_AqDxZh90BBVz4PRhtRIRetOgo0AR0pmrhzyICU',
response_type: 'code',
redirect_uri: newConnection.redirectUrl[0].replaceAll('*', ''),
code_challenge: 'OcMni5eZvSrQ2ev7tPICbcE7q1piL8Abi8IfJtWbUtY',
code_challenge_method: 'S256',
},
true
);
let res = await countSSOTracesByProduct(request, { product: newConnection.product });
expect(res.count).toBeGreaterThan(0);
await deleteSSOTraces(request, { product: newConnection.product });
res = await countSSOTracesByProduct(request, { product: newConnection.product });
expect(res.count).toBe(0);
});
});

View File

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

View File

@ -0,0 +1,20 @@
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 doCredentialsLogin() {
await this.page.goto('/admin/auth/login');
await this.page.getByPlaceholder('Email').fill('super@boxyhq.com');
await this.page.getByPlaceholder('Password').fill('999login');
await this.page.getByRole('button', { name: 'Sign In' }).click();
}
async isLoggedIn() {
// assert login state
await expect(this.userAvatarLocator).toBeVisible();
}
}

View File

@ -0,0 +1,178 @@
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 readonly toggleConnectionStatusCheckbox: Locator;
private readonly toggleConnectionStatusLabel: 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.toggleConnectionStatusCheckbox = this.page.getByRole('checkbox', { name: 'Active' });
this.toggleConnectionStatusLabel = this.page.locator('label').filter({ hasText: 'Active' });
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 toggleConnectionStatus(newStatus: boolean) {
const isChecked = await this.toggleConnectionStatusCheckbox.isChecked();
if (isChecked && !newStatus) {
await this.toggleConnectionStatusLabel.click();
await this.confirmButton.click();
} else if (!isChecked && newStatus) {
await this.toggleConnectionStatusLabel.click();
await this.confirmButton.click();
}
}
async updateSSOConnection({ name, url, newStatus }: { name: string; url: string; newStatus?: boolean }) {
await this.gotoEditView(name);
await this.redirectURLSInput.fill(url);
await this.saveConnection.click();
if (typeof newStatus === 'boolean') {
await this.gotoEditView(name);
await this.toggleConnectionStatus(newStatus);
}
}
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,110 @@
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>({
portal: async ({ page }, use) => {
const portal = new Portal(page);
await use(portal);
},
ssoPage: async ({ page, portal }, use) => {
const ssoPage = new SSOPage(page);
await ssoPage.goto();
await use(ssoPage);
await portal.doCredentialsLogin();
await portal.isLoggedIn();
await ssoPage.deleteAllSSOConnections();
},
});
test('OAuth2 wrapper + SAML provider + wrong redirectUrl', async ({ ssoPage, page, baseURL }, testInfo) => {
const ssoName = `saml-${testInfo.workerIndex}`;
await ssoPage.addSSOConnection({ name: ssoName, type: 'saml', baseURL: baseURL! });
// check if the first added connection appears in the connection list
await expect(page.getByText(`${ssoName}-1`)).toBeVisible();
await ssoPage.updateSSOConnection({
name: `${ssoName}-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();
});
test('OAuth2 wrapper + SAML provider + inactive connection', async ({ ssoPage, page, baseURL }, testInfo) => {
const ssoName = `saml-${testInfo.workerIndex}`;
await ssoPage.addSSOConnection({ name: ssoName, type: 'saml', baseURL: baseURL! });
// check if the first added connection appears in the connection list
await expect(page.getByText(`${ssoName}-1`)).toBeVisible();
await ssoPage.updateSSOConnection({
name: `${ssoName}-1`,
url: baseURL!,
newStatus: false,
});
// Confirm connection label inactive is displayed
await expect(
page.getByText(`${ssoName}-1`).locator('xpath=..').getByRole('cell', { name: 'Inactive', exact: true })
).toBeVisible();
// Logout and try to sign in with connection
// 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: SSO connection is deactivated. Please contact your administrator.')
).toBeVisible();
});
test('OAuth2 wrapper + OIDC provider + wrong redirectUrl', async ({ ssoPage, page, baseURL }, testInfo) => {
const ssoName = `oidc-${testInfo.workerIndex}`;
await ssoPage.addSSOConnection({ name: ssoName, type: 'oidc', baseURL: baseURL! });
// check if the oidc connection appears in the connection list
await expect(page.getByText(`${ssoName}-1`)).toBeVisible();
await ssoPage.updateSSOConnection({
name: `${ssoName}-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();
});
test('OAuth2 wrapper + OIDC provider + inactive connection', async ({ ssoPage, page, baseURL }, testInfo) => {
const ssoName = `oidc-${testInfo.workerIndex}`;
await ssoPage.addSSOConnection({ name: ssoName, type: 'oidc', baseURL: baseURL! });
// check if the oidc connection appears in the connection list
await expect(page.getByText(`${ssoName}-1`)).toBeVisible();
await ssoPage.updateSSOConnection({
name: `${ssoName}-1`,
url: baseURL!,
newStatus: false,
});
// Confirm connection label inactive is displayed
await expect(
page.getByText(`${ssoName}-1`).locator('xpath=..').getByRole('cell', { name: 'Inactive', exact: true })
).toBeVisible();
// Logout and try to sign in with connection
// 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: SSO connection is deactivated. Please contact your administrator.')
).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 MockSAML
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,4 +22,4 @@ patches:
images:
- name: boxyhq/jackson
newTag: 1.23.5
newTag: 1.23.7

View File

@ -22,4 +22,4 @@ patches:
images:
- name: boxyhq/jackson
newTag: 1.23.5
newTag: 1.23.7

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "jackson",
"version": "1.23.6",
"version": "1.23.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jackson",
"version": "1.23.6",
"version": "1.23.7",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "jackson",
"version": "1.23.6",
"version": "1.23.7",
"private": true,
"description": "SAML 2.0 service",
"keywords": [

View File

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