Skip to content

Commit

Permalink
[FTR] add service to test user roles on serverless (elastic#170417)
Browse files Browse the repository at this point in the history
## Summary

### This PR enables user roles testing in FTR

We use SAML authentication to get session cookie for user with the
specific role. The cookie is cached on FTR service side so we only make
SAML auth one time per user within FTR config run. For Kibana CI service
relies on changes coming in elastic#170852

In order to run FTR tests locally against existing MKI project:
- add `.ftr/role_users.json` in Kibana root dir
```
{
  "viewer": {
    "email": "...",
    "password": "..."
  },
  "developer": {
    "email": "...",
    "password": "..."
  }
}

```
- set Cloud hostname (!not project hostname!) with TEST_CLOUD_HOST_NAME,
e.g.
`export TEST_CLOUD_HOST_NAME=console.qa.cld.elstc.co`


### How to use:

- functional tests:
```
const svlCommonPage = getPageObject('svlCommonPage');

before(async () => {
  // login with Viewer role  
  await svlCommonPage.loginWithRole('viewer');
  // you are logged in in browser and on project home page, start the test 
});

it('has project header', async () => {
  await svlCommonPage.assertProjectHeaderExists();
});
```

- API integration tests:
```
const svlUserManager = getService('svlUserManager');
const supertestWithoutAuth = getService('supertestWithoutAuth');
let credentials: { Cookie: string };

before(async () => {
  // get auth header for Viewer role  
 credentials = await svlUserManager.getApiCredentialsForRole('viewer');
});

it('returns full status payload for authenticated request', async () => {
    const { body } = await supertestWithoutAuth
    .get('/api/status')
    .set(credentials)
    .set('kbn-xsrf', 'kibana');

    expect(body.name).to.be.a('string');
    expect(body.uuid).to.be.a('string');
    expect(body.version.number).to.be.a('string');
});
```

Flaky-test-runner: 

#1
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4081
#2
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4114

---------

Co-authored-by: Robert Oskamp <traeluki@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
  • Loading branch information
4 people authored Dec 4, 2023
1 parent 7421034 commit d75103e
Show file tree
Hide file tree
Showing 13 changed files with 578 additions and 18 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,7 @@ fleet-server.yml
**/.journeys/
x-pack/test/security_api_integration/plugins/audit_log/audit.log

# ignore FTR temp directory
.ftr
role_users.json

4 changes: 2 additions & 2 deletions packages/kbn-es/src/utils/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,9 @@ describe('resolveEsArgs()', () => {
"--env",
"xpack.security.authc.realms.saml.mock-idp.attributes.groups=http://saml.elastic-cloud.com/attributes/roles",
"--env",
"xpack.security.authc.realms.saml.mock-idp.attributes.name=http://saml.elastic-cloud.com/attributes/email",
"xpack.security.authc.realms.saml.mock-idp.attributes.name=http://saml.elastic-cloud.com/attributes/name",
"--env",
"xpack.security.authc.realms.saml.mock-idp.attributes.mail=http://saml.elastic-cloud.com/attributes/name",
"xpack.security.authc.realms.saml.mock-idp.attributes.mail=http://saml.elastic-cloud.com/attributes/email",
]
`);
});
Expand Down
4 changes: 2 additions & 2 deletions packages/kbn-es/src/utils/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,11 +508,11 @@ export function resolveEsArgs(
);
esArgs.set(
`xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.name`,
MOCK_IDP_ATTRIBUTE_EMAIL
MOCK_IDP_ATTRIBUTE_NAME
);
esArgs.set(
`xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.mail`,
MOCK_IDP_ATTRIBUTE_NAME
MOCK_IDP_ATTRIBUTE_EMAIL
);
}

Expand Down
46 changes: 40 additions & 6 deletions test/functional/services/common/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@
* Side Public License, v 1.
*/

import Url from 'url';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import { modifyUrl } from '@kbn/std';
import { cloneDeepWith, isString } from 'lodash';
import { Key, Origin, type WebDriver } from 'selenium-webdriver';
import { Driver as ChromiumWebDriver } from 'selenium-webdriver/chrome';
import { modifyUrl } from '@kbn/std';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import Url from 'url';

import sharp from 'sharp';
import { NoSuchSessionError } from 'selenium-webdriver/lib/error';
import sharp from 'sharp';
import { FtrService, type FtrProviderContext } from '../../ftr_provider_context';
import { WebElementWrapper } from '../lib/web_element_wrapper';
import { type FtrProviderContext, FtrService } from '../../ftr_provider_context';
import { Browsers } from '../remote/browsers';
import {
NETWORK_PROFILES,
type NetworkOptions,
type NetworkProfile,
NETWORK_PROFILES,
} from '../remote/network_profiles';

export type Browser = BrowserService;
Expand Down Expand Up @@ -246,6 +246,28 @@ class BrowserService extends FtrService {
return await this.driver.get(url);
}

/**
* Deletes all the cookies of the current browsing context.
* https://www.selenium.dev/documentation/webdriver/interactions/cookies/#delete-all-cookies
*
* @return {Promise<void>}
*/
public async deleteAllCookies() {
await this.driver.manage().deleteAllCookies();
}

/**
* Adds a cookie to the current browsing context. You need to be on the domain that the cookie will be valid for.
* https://www.selenium.dev/documentation/webdriver/interactions/cookies/#add-cookie
*
* @param {string} name
* @param {string} value
* @return {Promise<void>}
*/
public async setCookie(name: string, value: string) {
await this.driver.manage().addCookie({ name, value });
}

/**
* Retrieves the cookie with the given name. Returns null if there is no such cookie. The cookie will be returned as
* a JSON object as described by the WebDriver wire protocol.
Expand All @@ -258,6 +280,18 @@ class BrowserService extends FtrService {
return await this.driver.manage().getCookie(cookieName);
}

/**
* Returns a ‘successful serialized cookie data’ for current browsing context.
* If browser is no longer available it returns error.
* https://www.selenium.dev/documentation/webdriver/interactions/cookies/#get-all-cookies
*
* @param {string} cookieName
* @return {Promise<IWebDriverCookie>}
*/
public async getCookies() {
return await this.driver.manage().getCookies();
}

/**
* Pauses the execution in the browser, similar to setting a breakpoint for debugging.
* @return {Promise<void>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./role_mappings'));
loadTestFile(require.resolve('./sessions'));
loadTestFile(require.resolve('./users'));
loadTestFile(require.resolve('./request_as_viewer'));
loadTestFile(require.resolve('./user_profiles'));
loadTestFile(require.resolve('./views'));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../../ftr_provider_context';

export default function ({ getService }: FtrProviderContext) {
describe('security/request as viewer', () => {
const svlUserManager = getService('svlUserManager');
const supertestWithoutAuth = getService('supertestWithoutAuth');
let credentials: { Cookie: string };

before(async () => {
// get auth header for Viewer role
credentials = await svlUserManager.getApiCredentialsForRole('viewer');
});

it('returns full status payload for authenticated request', async () => {
const { body } = await supertestWithoutAuth
.get('/api/status')
.set(credentials)
.set('kbn-xsrf', 'kibana');

expect(body.name).to.be.a('string');
expect(body.uuid).to.be.a('string');
expect(body.version.number).to.be.a('string');
});
});
}
52 changes: 48 additions & 4 deletions x-pack/test_serverless/functional/page_objects/svl_common_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,64 @@ export function SvlCommonPageProvider({ getService, getPageObjects }: FtrProvide
const deployment = getService('deployment');
const log = getService('log');
const browser = getService('browser');
const svlUserManager = getService('svlUserManager');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const svlCommonApi = getService('svlCommonApi');

const delay = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});

return {
async loginWithRole(role: string) {
await retry.waitForWithTimeout(
`Logging in by setting browser cookie for '${role}' role`,
30_000,
async () => {
log.debug(`Delete all the cookies in the current browser context`);
await browser.deleteAllCookies();
log.debug(`Setting the cookie for '${role}' role`);
const sidCookie = await svlUserManager.getSessionCookieForRole(role);
// Loading bootstrap.js in order to be on the domain that the cookie will be set for.
await browser.get(deployment.getHostPort() + '/bootstrap.js');
await browser.setCookie('sid', sidCookie);
// Cookie should be already set in the browsing context, navigating to the Home page
await browser.get(deployment.getHostPort());
// Verifying that we are logged in
if (await testSubjects.exists('userMenuButton', { timeout: 10_000 })) {
log.debug('userMenuButton found, login passed');
} else {
throw new Error(`Failed to login with cookie for '${role}' role`);
}

// Validating that the new cookie in the browser is set for the correct user
const browserCookies = await browser.getCookies();
if (browserCookies.length === 0) {
throw new Error(`The cookie is missing in browser context`);
}
const { body } = await supertestWithoutAuth
.get('/internal/security/me')
.set(svlCommonApi.getInternalRequestHeader())
.set('Cookie', `sid=${browserCookies[0].value}`);

const userData = await svlUserManager.getUserData(role);
// email returned from API call must match the email for the specified role
if (body.email === userData.email) {
log.debug(`The new cookie is properly set for '${role}' role`);
return true;
} else {
throw new Error(
`Cookie is not set properly, expected email is '${userData.email}', but found '${body.email}'`
);
}
}
);
},

async navigateToLoginForm() {
const url = deployment.getHostPort() + '/login';
await browser.get(url);
// ensure welcome screen won't be shown. This is relevant for environments which don't allow
// to use the yml setting, e.g. cloud
await browser.setLocalStorageItem('home:welcome:show', 'false');

log.debug('Waiting for Login Form to appear.');
await retry.waitForWithTimeout('login form', 10_000, async () => {
return await pageObjects.security.isLoginFormVisible();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context';

export default function ({ loadTestFile }: FtrProviderContext) {
describe('Serverless Common UI - Platform Security', function () {
loadTestFile(require.resolve('./viewer_role_login'));
loadTestFile(require.resolve('./api_keys'));
loadTestFile(require.resolve('./navigation/avatar_menu'));
loadTestFile(require.resolve('./user_profiles/user_profiles'));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';

const VIEWER_ROLE = 'viewer';

export default function ({ getPageObject, getService }: FtrProviderContext) {
describe(`Login as ${VIEWER_ROLE}`, function () {
const svlCommonPage = getPageObject('svlCommonPage');
const testSubjects = getService('testSubjects');
const svlUserManager = getService('svlUserManager');

before(async () => {
await svlCommonPage.loginWithRole(VIEWER_ROLE);
});

it('should be able to see correct profile', async () => {
await svlCommonPage.assertProjectHeaderExists();
await svlCommonPage.assertUserAvatarExists();
await svlCommonPage.clickUserAvatar();
await svlCommonPage.assertUserMenuExists();
const actualFullname = await testSubjects.getVisibleText('contextMenuPanelTitle');
const userData = await svlUserManager.getUserData(VIEWER_ROLE);
expect(actualFullname).to.be(userData.fullname);
});
});
}
2 changes: 2 additions & 0 deletions x-pack/test_serverless/shared/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import { SvlReportingServiceProvider } from './svl_reporting';
import { SupertestProvider, SupertestWithoutAuthProvider } from './supertest';
import { SvlCommonApiServiceProvider } from './svl_common_api';
import { SvlUserManagerProvider } from './user_manager/svl_user_manager';

export const services = {
supertest: SupertestProvider,
supertestWithoutAuth: SupertestWithoutAuthProvider,
svlCommonApi: SvlCommonApiServiceProvider,
svlReportingApi: SvlReportingServiceProvider,
svlUserManager: SvlUserManagerProvider,
};
16 changes: 12 additions & 4 deletions x-pack/test_serverless/shared/services/supertest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,29 @@ import { format as formatUrl } from 'url';
import supertest from 'supertest';
import { FtrProviderContext } from '../../functional/ftr_provider_context';

/**
* Returns supertest.SuperTest<supertest.Test> instance that will not persist cookie between API requests.
*/
export function SupertestProvider({ getService }: FtrProviderContext) {
const config = getService('config');
const kbnUrl = formatUrl(config.get('servers.kibana'));
const ca = config.get('servers.kibana').certificateAuthorities;

return supertest.agent(kbnUrl, { ca });
return supertest(kbnUrl);
}

/**
* Returns supertest.SuperTest<supertest.Test> instance that will not persist cookie between API requests.
* If you need to pass certificate, do the following:
* await supertestWithoutAuth
* .get('/abc')
* .ca(CA_CERT)
*/
export function SupertestWithoutAuthProvider({ getService }: FtrProviderContext) {
const config = getService('config');
const kbnUrl = formatUrl({
...config.get('servers.kibana'),
auth: false,
});
const ca = config.get('servers.kibana').certificateAuthorities;

return supertest.agent(kbnUrl, { ca });
return supertest(kbnUrl);
}
Loading

0 comments on commit d75103e

Please sign in to comment.