Browse Source

Merge pull request #7569 from nocodb/nc-feat/sso-saml-openid

Nc - SSO tests
pull/7577/head
Pranav C 10 months ago committed by GitHub
parent
commit
7b32d45327
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 25
      packages/nc-gui/components/nc/Select.vue
  2. 3
      packages/nc-gui/composables/useApi/interceptors.ts
  3. 2
      packages/nc-gui/composables/useGlobal/state.ts
  4. 2
      packages/nc-gui/composables/useGlobal/types.ts
  5. 28
      packages/nc-gui/lang/en.json
  6. 41
      packages/nc-gui/middleware/auth.global.ts
  7. 4
      packages/nc-gui/utils/urlUtils.ts
  8. 10
      packages/nc-gui/utils/validation.ts
  9. 23
      packages/nocodb/src/controllers/auth/auth.controller.ts
  10. 7
      packages/nocodb/src/db/util/DebugMgr.ts
  11. 3
      packages/nocodb/src/helpers/apiHelpers.ts
  12. 12
      packages/nocodb/src/middlewares/global/global.middleware.ts
  13. 23
      packages/nocodb/src/services/users/users.service.ts
  14. 17
      packages/nocodb/src/services/utils.service.ts
  15. 4
      packages/nocodb/src/strategies/jwt.strategy.ts
  16. 1
      packages/nocodb/src/types/express.d.ts
  17. 1
      packages/nocodb/src/utils/globals.ts
  18. 7
      packages/nocodb/tests/unit/init/cleanupMeta.ts
  19. 4
      packages/nocodb/tests/unit/init/index.ts
  20. 3
      packages/nocodb/tests/unit/rest/index.test.ts
  21. 162
      tests/playwright/pages/Account/Authentication.ts
  22. 3
      tests/playwright/pages/Account/index.ts
  23. 41
      tests/playwright/pages/SsoIdpPage/OpenIDLoginPage.ts
  24. 38
      tests/playwright/pages/SsoIdpPage/SAMLLoginPage.ts
  25. 19
      tests/playwright/setup/index.ts

25
packages/nc-gui/components/nc/Select.vue

@ -1,7 +1,8 @@
<script lang="ts" setup>
const props = defineProps<{
value?: string
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
@ -31,6 +32,8 @@ const dropdownMatchSelectWidth = computed(() => props.dropdownMatchSelectWidth)
const loading = computed(() => props.loading)
const mode = computed(() => props.mode)
const vModel = useVModel(props, 'value', emits)
const onChange = (value: string) => {
@ -41,20 +44,21 @@ const onChange = (value: string) => {
<template>
<a-select
v-model:value="vModel"
:placeholder="placeholder"
class="nc-select"
:allow-clear="allowClear"
:disabled="loading"
:dropdown-class-name="dropdownClassName"
:show-search="showSearch"
:filter-option="filterOption"
:dropdown-match-select-width="dropdownMatchSelectWidth"
:allow-clear="allowClear"
:filter-option="filterOption"
:loading="loading"
:disabled="loading"
:mode="mode"
:placeholder="placeholder"
:show-search="showSearch"
class="nc-select"
@change="onChange"
>
<template #suffixIcon>
<GeneralLoader v-if="loading" />
<GeneralIcon v-else icon="arrowDown" class="text-gray-800 nc-select-expand-btn" />
<GeneralIcon v-else class="text-gray-800 nc-select-expand-btn" icon="arrowDown" />
</template>
<slot />
</a-select>
@ -82,12 +86,15 @@ const onChange = (value: string) => {
}
.ant-select-selection-item {
@apply font-medium pr-3;
@apply font-medium pr-3 rounded-md;
}
.ant-select-selection-placeholder {
@apply text-gray-600;
}
.ant-select-selection-item-remove {
@apply text-gray-800 !pb-1;
}
}
.nc-select.ant-select-focused:not(.ant-select-disabled).ant-select:not(.ant-select-customize-input) .ant-select-selector {
box-shadow: none;

3
packages/nc-gui/composables/useApi/interceptors.ts

@ -16,7 +16,8 @@ export function addAxiosInterceptors(api: Api<any>) {
axiosInstance.interceptors.request.use((config) => {
config.headers['xc-gui'] = 'true'
if (state.token.value) config.headers['xc-auth'] = state.token.value
// Add auth header only if signed in and if `xc-short-token` header is not present (for short-lived tokens used for token generation)
if (state.token.value && !config.headers['xc-short-token']) config.headers['xc-auth'] = state.token.value
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles') && state.previewAs?.value) {
config.headers['xc-preview'] = state.previewAs.value

2
packages/nc-gui/composables/useGlobal/state.ts

@ -96,6 +96,8 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
googleAuthEnabled: false,
oidcAuthEnabled: false,
oidcProviderName: null,
samlAuthEnabled: false,
samlProviderName: null,
ncMin: false,
oneClick: false,
baseHasAdmin: false,

2
packages/nc-gui/composables/useGlobal/types.ts

@ -35,6 +35,8 @@ export interface AppInfo {
mainSubDomain?: string
dashboardPath: string
inviteOnlySignup: boolean
samlAuthEnabled: boolean
samlProviderName: string | null
}
export interface StoredState {

28
packages/nc-gui/lang/en.json

@ -301,6 +301,7 @@
"isNotNull": "is not null"
},
"title": {
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
"parameter": "Parameter",
@ -417,6 +418,16 @@
}
},
"labels": {
"save": "Save",
"cancel": "Cancel",
"metadataUrl": "Metadata URL",
"audience-entityId": "Audience/ Entity ID",
"redirectUrl": "Redirect URL",
"oidc": "OpenID Connect (OIDC)",
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"ssoSettings": "SSO Settings",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
@ -513,6 +524,7 @@
"databaseType": "Type in Database",
"lengthValue": "Length/ value",
"dbType": "Database Type",
"servername": "servername / hostAddr",
"sqliteFile": "SQLite File",
"hostAddress": "Host Address",
"port": "Port Number",
@ -657,6 +669,9 @@
}
},
"activity": {
"googleOAuth": "Google OAuth",
"registerOIDC": "Register OIDC Identity Provider",
"registerSAML": "Register SAML Identity Provider",
"openInANewTab": "Open in a new tab",
"copyIFrameCode": "Copy IFrame code",
"onCondition": "On Condition",
@ -1069,6 +1084,8 @@
}
},
"info": {
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1233,6 +1250,16 @@
"notAvailableAtTheMoment": "Not available at the moment"
},
"error": {
"scopesRequired": "Scopes required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
"issuerRequired": "Issuer is required",
"clientSecretRequired": "Client Secret is required",
"jwkUrlRequired": "JWK URL is required",
"tokenUrlRequired": "Token URL is required",
"userInfoUrlRequired": "UserInfo URL is required",
"eitherXML": "Either xml or metadata url is required",
"nameRequired": "Name Required",
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
@ -1263,6 +1290,7 @@
"invalidEmails": "Invalid emails",
"invalidEmail": "Invalid Email"
},
"invalidXml": "Invalid XML",
"invalidURL": "Invalid URL",
"invalidEmail": "Invalid Email",
"internalError": "Some internal error occurred",

41
packages/nc-gui/middleware/auth.global.ts

@ -51,6 +51,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
await tryGoogleAuth(api, state.signIn)
}
/** if not signedIn try token population based on short-lived-token */
if (!state.signedIn.value) await tryShortTokenAuth(api, state.signIn)
/** if public allow all visitors */
if (to.meta.public) return
@ -155,3 +158,41 @@ async function tryGoogleAuth(api: Api<any>, signIn: Actions['signIn']) {
window.location.reload()
}
}
/**
* If short-token present, try using it to generate long-living token before navigating to the next page
*/
async function tryShortTokenAuth(api: Api<any>, signIn: Actions['signIn']) {
if (window.location.search && /\bshort-token=/.test(window.location.search)) {
let extraProps: any = {}
try {
// `extra` prop is used in our cloud implementation, so we are keeping it
const { data } = await api.instance.post(
`/auth/long-lived-token`,
{},
{
headers: {
'xc-short-token': window.location.search.split('=')[1],
} as any,
},
)
const { token, extra } = data
// if extra prop is null/undefined set it as an empty object as fallback
extraProps = extra || {}
signIn(token)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
const newURL = window.location.href.split('?')[0]
window.history.pushState(
'object',
document.title,
`${extraProps?.continueAfterSignIn ? `${newURL}#/?continueAfterSignIn=${extraProps.continueAfterSignIn}` : newURL}`,
)
window.location.reload()
}
}

4
packages/nc-gui/utils/urlUtils.ts

@ -27,8 +27,8 @@ export const replaceUrlsWithLink = (text: string): boolean | string => {
return found && out
}
export const isValidURL = (str: string) => {
return isURL(`${str}`)
export const isValidURL = (str: string, extraProps?) => {
return isURL(`${str}`, extraProps)
}
export const openLink = (path: string, baseURL?: string, target = '_blank') => {

10
packages/nc-gui/utils/validation.ts

@ -208,3 +208,13 @@ export const emailValidator = {
})
},
}
export const urlValidator = {
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (!v.length || isValidURL(v)) return resolve()
reject(new Error(t('msg.error.invalidURL')))
})
},
}

23
packages/nocodb/src/controllers/auth/auth.controller.ts

@ -18,12 +18,10 @@ import type { AppConfig } from '~/interface/config';
import { UsersService } from '~/services/users/users.service';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { randomTokenString, setTokenCookie } from '~/services/users/helpers';
import { GlobalGuard } from '~/guards/global/global.guard';
import { NcError } from '~/helpers/catchError';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { User } from '~/models';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
@ -246,25 +244,6 @@ export class AuthController {
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user['token_version']) {
user['token_version'] = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user['token_version'],
});
setTokenCookie(res, refreshToken);
await this.usersService.setRefreshToken({ res, req });
}
}

7
packages/nocodb/src/db/util/DebugMgr.ts

@ -89,10 +89,7 @@ export default class DebugMgr {
}
}
static disableAll(namespace) {
for (const key in levels) {
debug.disable(`${namespace}_${levels[key]}`);
this.refreshNamespace(namespace);
}
static disableAll() {
debug.disable();
}
}

3
packages/nocodb/src/helpers/apiHelpers.ts

@ -45,7 +45,8 @@ export const validatePayload = (schema: string, payload: any) => {
// If the request body is not valid, throw error
if (!valid) {
const errors: ErrorObject[] | null | undefined = ajv.errors;
const errors: ErrorObject[] | null | undefined =
ajv.errors || validate.errors;
// If the request body is invalid, throw error with error message and errors
NcError.ajvValidationError({

12
packages/nocodb/src/middlewares/global/global.middleware.ts

@ -1,15 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { NestMiddleware } from '@nestjs/common';
import type { AppConfig } from '~/interface/config';
import Noco from '~/Noco';
@Injectable()
export class GlobalMiddleware implements NestMiddleware {
constructor(protected readonly config: ConfigService<AppConfig>) {}
use(req: any, res: any, next: () => void) {
req.ncSiteUrl =
Noco.config?.envs?.[Noco.env]?.publicUrl ||
Noco.config?.publicUrl ||
req.protocol + '://' + req.get('host');
req.ncFullUrl = req.protocol + '://' + req.get('host') + req.originalUrl;
const dashboardPath = this.config.get('dashboardPath', {
infer: true,
});
// used for playwright tests so env is not documented
req.dashboardUrl =
process.env.NC_DASHBOARD_URL || req.ncSiteUrl + dashboardPath;
next();
}
}

23
packages/nocodb/src/services/users/users.service.ts

@ -555,4 +555,27 @@ export class UsersService {
return base;
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user['token_version']) {
user['token_version'] = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user['token_version'],
});
setTokenCookie(res, refreshToken);
}
}

17
packages/nocodb/src/services/utils.service.ts

@ -1,3 +1,4 @@
import process from 'process';
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { compareVersions, validate } from 'compare-versions';
@ -371,18 +372,18 @@ export class UtilsService {
settings = JSON.parse((await Store.get(NC_APP_SETTINGS, true))?.value);
} catch {}
const oidcAuthEnabled = !!(
process.env.NC_OIDC_ISSUER &&
process.env.NC_OIDC_AUTHORIZATION_URL &&
process.env.NC_OIDC_TOKEN_URL &&
process.env.NC_OIDC_USERINFO_URL &&
process.env.NC_OIDC_CLIENT_ID &&
process.env.NC_OIDC_CLIENT_SECRET
const oidcAuthEnabled = ['openid', 'oidc'].includes(
process.env.NC_SSO?.toLowerCase(),
);
const oidcProviderName = oidcAuthEnabled
? process.env.NC_OIDC_PROVIDER_NAME ?? 'OpenID Connect'
: null;
const samlAuthEnabled = process.env.NC_SSO?.toLowerCase() === 'saml';
const samlProviderName = samlAuthEnabled
? process.env.NC_SSO_SAML_PROVIDER_NAME ?? 'SAML'
: null;
const result = {
authType: 'jwt',
baseHasAdmin,
@ -422,6 +423,8 @@ export class UtilsService {
mainSubDomain: this.configService.get('mainSubDomain', { infer: true }),
dashboardPath: this.configService.get('dashboardPath', { infer: true }),
inviteOnlySignup: settings.invite_only_signup,
samlProviderName,
samlAuthEnabled,
};
return result;

4
packages/nocodb/src/strategies/jwt.strategy.ts

@ -14,7 +14,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(req, jwtPayload) {
if (!jwtPayload?.email) return jwtPayload;
if (!jwtPayload?.email) {
return jwtPayload;
}
const user = await User.getByEmail(jwtPayload?.email);

1
packages/nocodb/src/types/express.d.ts vendored

@ -10,5 +10,6 @@ declare module 'express-serve-static-core' {
};
ncSiteUrl: string;
clientIp: string;
dashboardUrl: string;
}
}

1
packages/nocodb/src/utils/globals.ts

@ -162,6 +162,7 @@ export enum CacheScope {
PROJECT_ALIAS = 'baseAlias',
MODEL_ALIAS = 'modelAlias',
VIEW_ALIAS = 'viewAlias',
SSO_CLIENT = 'ssoClient',
}
export enum CacheGetType {

7
packages/nocodb/tests/unit/init/cleanupMeta.ts

@ -1,8 +1,7 @@
import { Base, Model } from '../../../src/models';
import NcConnectionMgrv2 from '../../../src/utils/common/NcConnectionMgrv2';
import { orderedMetaTables } from '../../../src/utils/globals';
import { Base, Model } from '~/models';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { orderedMetaTables } from '~/utils/globals';
import TestDbMngr from '../TestDbMngr';
import { isPg } from './db';
const dropTablesAllNonExternalProjects = async () => {
const bases = await Base.list({});

4
packages/nocodb/tests/unit/init/index.ts

@ -41,7 +41,9 @@ export default async function (forceReset = false, roles = 'editor') {
const { token, user } = await createUser({ app: server }, { roles });
const extra: any = {};
const extra: {
fk_workspace_id?: string;
} = {};
// create ws for ee
if (process.env.EE === 'true') {

3
packages/nocodb/tests/unit/rest/index.test.ts

@ -13,8 +13,10 @@ import groupByTest from './tests/groupby.test';
import formulaTests from './tests/formula.test';
let workspaceTest = () => {};
let ssoTest = () => {};
if (process.env.EE === 'true') {
workspaceTest = require('./tests/ee/workspace.test').default;
ssoTest = require('./tests/ee/sso.test').default;
}
// import layoutTests from './tests/layout.test';
// import widgetTest from './tests/widget.test';
@ -33,6 +35,7 @@ function restTests() {
groupByTest();
workspaceTest();
formulaTests();
ssoTest();
// Enable for dashboard feature
// widgetTest();

162
tests/playwright/pages/Account/Authentication.ts

@ -0,0 +1,162 @@
import BasePage from '../Base';
import { AccountPage } from './index';
import * as assert from 'assert';
import { expect } from '@playwright/test';
export class AccountAuthenticationPage extends BasePage {
private accountPage: AccountPage;
constructor(accountPage: AccountPage) {
super(accountPage.rootPage);
this.accountPage = accountPage;
}
async goto() {
await this.waitForResponse({
uiAction: () => this.rootPage.goto('/#/account/authentication', { waitUntil: 'networkidle' }),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
});
}
get() {
return this.accountPage.get().locator(`[data-test-id="nc-authentication"]`);
}
async verifySAMLProviderCount({ count }: { count: number }) {
await expect.poll(async () => await this.get().locator('.nc-saml-provider').count()).toBe(count);
}
async verifyOIDCProviderCount({ count }: { count: number }) {
await expect.poll(async () => await this.get().locator('.nc-oidc-provider').count()).toBe(count);
}
async getProvider(provider: 'saml' | 'oidc', title: string) {
return this.rootPage.locator(`[data-test-id="nc-${provider}-provider-${title}"]`);
}
async deleteProvider(provider: 'saml' | 'oidc', title: string) {
await this.rootPage.locator(`.nc-${provider}-${title}-more-option`).click();
await this.waitForResponse({
uiAction: () => this.rootPage.locator(`[data-test-id="nc-${provider}-delete"]`).click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: `/api/v2/sso-client/`,
});
}
async toggleProvider(provider: 'saml' | 'oidc', title: string) {
await this.waitForResponse({
uiAction: () => this.get().locator(`.nc-${provider}-${title}-enable .nc-switch`).click(),
httpMethodsToMatch: ['PATCH'],
requestUrlPathToMatch: `/api/v2/sso-client/`,
});
}
async selectScope({ type, locator }: { type: string[] }) {
await this.rootPage.locator('.ant-select-selector').click();
await this.rootPage.locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
for (const t of type) {
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${t}"`).click();
}
}
async createSAMLProvider(
p: { title: string; url?: string; xml?: string },
setupRedirectUrlCbk?: ({ redirectUrl: string, audience: string }) => Promise<void>
) {
const newSamlBtn = this.get().locator('[data-test-id="nc-new-saml-provider"]');
await newSamlBtn.click();
const samlModal = this.accountPage.rootPage.locator('.nc-saml-modal');
// wait until redirect url is generated
await samlModal.locator('[data-test-id="nc-saml-redirect-url"]:has-text("http://")').waitFor();
if (setupRedirectUrlCbk) {
const redirectUrl = (
await samlModal.locator('[data-test-id="nc-saml-redirect-url"]:has-text("http://")').textContent()
).trim();
const audience = (
await samlModal.locator('[data-test-id="nc-saml-issuer-url"]:has-text("http://")').textContent()
).trim();
await setupRedirectUrlCbk({ redirectUrl, audience });
}
await samlModal.locator('[data-test-id="nc-saml-title"]').fill(p.title);
if (p.url) {
await samlModal.locator('[data-test-id="nc-saml-metadata-url"]').fill(p.url);
}
if (p.xml) {
await samlModal.locator('[data-test-id="nc-saml-xml"]').fill(p.xml);
}
await this.waitForResponse({
uiAction: () => samlModal.locator('[data-test-id="nc-saml-submit"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
});
}
async createOIDCProvider(
p: {
issuer: string;
title: string;
clientId: string;
clientSecret: string;
authUrl: string;
userInfoUrl: string;
tokenUrl: string;
jwkUrl: string;
scopes: Array<string>;
userAttributes: string;
},
setupRedirectUrlCbk?: ({ redirectUrl: string }) => Promise<void>
) {
const newOIDCBtn = this.get().locator('[data-test-id="nc-new-oidc-provider"]');
await newOIDCBtn.click();
const oidcModal = this.accountPage.rootPage.locator('.nc-oidc-modal');
// wait until redirect url is generated
await oidcModal.locator('[data-test-id="nc-openid-redirect-url"]:has-text("http://")').waitFor();
if (setupRedirectUrlCbk) {
const redirectUrl = (
await oidcModal.locator('[data-test-id="nc-openid-redirect-url"]:has-text("http://")').textContent()
).trim();
await setupRedirectUrlCbk({ redirectUrl });
}
await oidcModal.locator('[data-test-id="nc-oidc-title"]').fill(p.title);
await oidcModal.locator('[data-test-id="nc-oidc-issuer"]').fill(p.issuer);
await oidcModal.locator('[data-test-id="nc-oidc-client-id"]').fill(p.clientId);
await oidcModal.locator('[data-test-id="nc-oidc-client-secret"]').fill(p.clientSecret);
await oidcModal.locator('[data-test-id="nc-oidc-auth-url"]').fill(p.authUrl);
await oidcModal.locator('[data-test-id="nc-oidc-token-url"]').fill(p.tokenUrl);
await oidcModal.locator('[data-test-id="nc-oidc-user-info-url"]').fill(p.userInfoUrl);
await oidcModal.locator('[data-test-id="nc-oidc-jwk-url"]').fill(p.jwkUrl);
await this.selectScope({
type: p.scopes,
locator: oidcModal.locator('[data-test-id="nc-oidc-scope"]'),
});
await oidcModal.locator('[data-test-id="nc-oidc-user-attribute"]').fill(p.userAttributes);
await this.waitForResponse({
uiAction: () => oidcModal.locator('[data-test-id="nc-oidc-save-btn"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
});
}
}

3
tests/playwright/pages/Account/index.ts

@ -5,6 +5,7 @@ import { AccountTokenPage } from './Token';
import { AccountUsersPage } from './Users';
import { AccountAppStorePage } from './AppStore';
import { AccountLicensePage } from './License';
import { AccountAuthenticationPage } from './Authentication';
export class AccountPage extends BasePage {
readonly settings: AccountSettingsPage;
@ -12,6 +13,7 @@ export class AccountPage extends BasePage {
readonly users: AccountUsersPage;
readonly appStore: AccountAppStorePage;
readonly license: AccountLicensePage;
readonly authentication: AccountAuthenticationPage;
constructor(page: Page) {
super(page);
@ -20,6 +22,7 @@ export class AccountPage extends BasePage {
this.users = new AccountUsersPage(this);
this.appStore = new AccountAppStorePage(this);
this.license = new AccountLicensePage(this);
this.authentication = new AccountAuthenticationPage(this);
}
get() {

41
tests/playwright/pages/SsoIdpPage/OpenIDLoginPage.ts

@ -0,0 +1,41 @@
import { Page } from '@playwright/test';
import BasePage from '../Base';
import { ProjectsPage } from '../ProjectsPage';
export class OpenIDLoginPage extends BasePage {
readonly projectsPage: ProjectsPage;
constructor(rootPage: Page) {
super(rootPage);
this.projectsPage = new ProjectsPage(rootPage);
}
async goto(title = 'test') {
// reload page to get latest app info
await this.rootPage.reload({ waitUntil: 'networkidle' });
// click sign in with SAML
await this.rootPage.locator(`button:has-text("Sign in with ${title}")`).click();
}
get() {
return this.rootPage.locator('html');
}
async signIn({ email }: { email: string }) {
const signIn = this.get();
await signIn.locator('[name="login"]').waitFor();
await signIn.locator(`[name="login"]`).fill(email);
await signIn.locator(`[name="password"]`).fill('dummy-password');
await signIn.locator(`[type="submit"]`).click();
const authorize = this.get();
await Promise.all([
this.rootPage.waitForNavigation({ url: /localhost:3000/ }),
authorize.locator(`[type="submit"]`).click(),
]);
await this.rootPage.locator(`[data-testid="nc-sidebar-userinfo"]:has-text("${email.split('@')[0]}")`);
}
}

38
tests/playwright/pages/SsoIdpPage/SAMLLoginPage.ts

@ -0,0 +1,38 @@
import { Page } from '@playwright/test';
import BasePage from '../Base';
import { ProjectsPage } from '../ProjectsPage';
import { expect } from '@playwright/test';
export class SAMLLoginPage extends BasePage {
readonly projectsPage: ProjectsPage;
constructor(rootPage: Page) {
super(rootPage);
this.projectsPage = new ProjectsPage(rootPage);
}
async goto(title = 'test') {
// reload page to get latest app info
await this.rootPage.reload({ waitUntil: 'networkidle' });
// click sign in with SAML
await this.rootPage.locator(`button:has-text("Sign in with ${title}")`).click();
}
get() {
return this.rootPage.locator('html');
}
async signIn({ email }: { email: string }) {
const signIn = this.get();
await signIn.locator('#userName').waitFor();
await signIn.locator(`#userName`).fill(email);
await signIn.locator(`#email`).fill(email);
await Promise.all([
this.rootPage.waitForNavigation({ url: /localhost:3000/ }),
signIn.locator(`#btn-sign-in`).click(),
]);
await this.rootPage.locator(`[data-testid="nc-sidebar-userinfo"]:has-text("${email.split('@')[0]}")`);
}
}

19
tests/playwright/setup/index.ts

@ -181,7 +181,7 @@ async function localInit({
try {
let response: AxiosResponse<any, any>;
// Login as root user
if (isSuperUser && !isEE()) {
if (isSuperUser && process.env.NC_CLOUD !== 'true') {
// required for configuring license key settings
response = await axios.post('http://localhost:8080/api/v1/auth/user/signin', {
email: `user@nocodb.com`,
@ -209,6 +209,18 @@ async function localInit({
// console.log(process.env.TEST_WORKER_INDEX, process.env.TEST_PARALLEL_INDEX);
// delete sso-clients
if (isEE() && api['ssoClient'] && isSuperUser) {
const clients = await api.ssoClient.list();
for (const client of clients.list) {
try {
await api.ssoClient.delete(client.id);
} catch (e) {
console.log(`Error deleting sso-client: ${client.id}`);
}
}
}
if (isEE() && api['workspace']) {
// Delete associated workspace
// Note that: on worker error, entire thread is reset & worker ID numbering is reset too
@ -398,9 +410,9 @@ const setup = async ({
// ignore error: some roles will not have permission for license reset
// console.error(`Error resetting base: ${process.env.TEST_PARALLEL_INDEX}`, e);
}
await page.addInitScript(
async ({ token }) => {
if (location.search?.match(/code=|short-token=|skip-init-script=/)) return;
try {
let initialLocalStorage = {};
try {
@ -408,6 +420,9 @@ const setup = async ({
} catch (e) {
console.error('Failed to parse local storage', e);
}
if (initialLocalStorage?.token) return;
window.localStorage.setItem(
'nocodb-gui-v2',
JSON.stringify({

Loading…
Cancel
Save