Skip to content

Commit

Permalink
test: create test users through the API [INFENG-673] (#9431)
Browse files Browse the repository at this point in the history
  • Loading branch information
djanicekpach authored May 29, 2024
1 parent ac459f7 commit 0599d0e
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 179 deletions.
2 changes: 1 addition & 1 deletion webui/react/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ done.stamp
/test-results/
/playwright-report/
/playwright/.cache/
state.json
*-state.json
src/e2e/test-results/
src/e2e/playwright-report/
src/e2e/junit-results.xml
Expand Down
9 changes: 7 additions & 2 deletions webui/react/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import * as dotenv from 'dotenv';

import {
defineConfig,
devices,
} from '@playwright/test';

dotenv.config();

const serverAddess = process.env.PW_SERVER_ADDRESS;
Expand Down Expand Up @@ -37,7 +42,7 @@ export default defineConfig({

{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
use: { ...devices['Desktop Firefox']},
},

{
Expand Down
54 changes: 45 additions & 9 deletions webui/react/src/e2e/fixtures/api.auth.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,93 @@
import { APIRequest, APIRequestContext, Browser, BrowserContext, Page } from '@playwright/test';
import { v4 } from 'uuid';

export class ApiAuthFixture {
apiContext: APIRequestContext | undefined; // we can't get this until login, so may be undefined
apiContext?: APIRequestContext; // we can't get this until login, so may be undefined
readonly request: APIRequest;
readonly browser: Browser;
_page: Page | undefined;
readonly baseURL: string;
readonly testId = v4();
_page?: Page;
get page(): Page {
if (this._page === undefined) {
throw new Error('Accessing page object before initialization in authentication');
}
return this._page;
}
readonly #STATE_FILE = 'state.json';
readonly #STATE_FILE_SUFFIX = 'state.json';
readonly #USERNAME: string;
readonly #PASSWORD: string;
context: BrowserContext | undefined;
constructor(request: APIRequest, browser: Browser, existingPage: Page | undefined = undefined) {
context?: BrowserContext;
readonly #stateFile = `${this.testId}-${this.#STATE_FILE_SUFFIX}`;

constructor(request: APIRequest, browser: Browser, baseURL?: string, existingPage?: Page) {
if (process.env.PW_USER_NAME === undefined) {
throw new Error('username must be defined');
}
if (process.env.PW_PASSWORD === undefined) {
throw new Error('password must be defined');
}
if (baseURL === undefined) {
throw new Error('baseURL must be defined in playwright config to use API requests.');
}
this.#USERNAME = process.env.PW_USER_NAME;
this.#PASSWORD = process.env.PW_PASSWORD;
this.request = request;
this.browser = browser;
this.baseURL = baseURL;
this._page = existingPage;
}

async getBearerToken(): Promise<string> {
const cookies = (await this.apiContext?.storageState())?.cookies ?? [];
const authToken = cookies.find((cookie) => {
return cookie.name === 'auth';
})?.value;
if (authToken === undefined) {
throw new Error(
'Attempted to retrieve the auth token from the PW apiContext, but it does not exist. Have you called apiAuth.login() yet?',
);
}
return `Bearer ${authToken}`;
}

/**
* Logs in via the API. If there is a browser context already assosciated with the
* fixture, the bearer token will be attached to that context. If not a new
* browser ontext will be created with the cookie.
*/
async login(): Promise<void> {
this.apiContext = await this.request.newContext();
await this.apiContext.post('/api/v1/auth/login', {
const resp = await this.apiContext.post('/api/v1/auth/login', {
data: {
isHashed: false,
password: this.#PASSWORD,
username: this.#USERNAME,
},
});
if (resp.status() !== 200) {
throw new Error(`Login API request has failed with status code ${resp.status()}`);
}
// Save cookie state into the file.
const state = await this.apiContext.storageState({ path: this.#STATE_FILE });
const state = await this.apiContext.storageState({ path: this.#stateFile });
if (this._page !== undefined) {
// add cookies to current page's existing context
this.context = this._page.context();
await this.context.addCookies(state.cookies);
} else {
// Create a new context for the browser with the saved token.
this.context = await this.browser.newContext({ storageState: this.#STATE_FILE });
this.context = await this.browser.newContext({ storageState: this.#stateFile });
this._page = await this.context.newPage();
}
}

async logout(): Promise<void> {
/**
* This disposes of all API resources. It should only be called
* in afterAll. In that case new contexts will have been manually
* provisioned. If you dispose of these contexts mid-test any
* tests still using them test will fail.
*/
async dispose(): Promise<void> {
await this.apiContext?.dispose();
await this.context?.close();
}
Expand Down
86 changes: 86 additions & 0 deletions webui/react/src/e2e/fixtures/api.user.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import streamConsumers from 'stream/consumers';

import _ from 'lodash';

import { safeName } from 'e2e/utils/naming';
import { UsersApi, V1PatchUser, V1PostUserRequest, V1User } from 'services/api-ts-sdk/api';

import { ApiAuthFixture } from './api.auth.fixture';

export class ApiUserFixture {
readonly apiAuth: ApiAuthFixture;
constructor(apiAuth: ApiAuthFixture) {
this.apiAuth = apiAuth;
}

newRandom(usernamePrefix = 'test-user'): V1PostUserRequest {
return {
isHashed: false,
password: 'TestPassword1',
user: {
active: true,
admin: true,
username: safeName(usernamePrefix),
},
};
}

private static normalizeUrl(url: string): string {
if (url.endsWith('/')) {
return url.substring(0, url.length - 1);
}
return url;
}

private async startUserRequest(): Promise<UsersApi> {
return new UsersApi(
{ apiKey: await this.apiAuth.getBearerToken() },
ApiUserFixture.normalizeUrl(this.apiAuth.baseURL),
fetch,
);
}

/**
* Creates a user with the given parameters via the API.
* @param {V1PostUserRequest} req the user request with the config for the new user.
* See apiUser.newRandom() for the default config.
* @returns {Promise<V1PostUserRequest>} Representation of the created user. The request is returned since the
* password is not stored on the V1User object and it is not returned in the response. However the Request is a
* strict superset of the Response, so no info is lost.
*/
async createUser(req: V1PostUserRequest): Promise<V1PostUserRequest> {
const userResp = await (await this.startUserRequest())
.postUser(req, {})
.catch(async function (error) {
const respBody = await streamConsumers.text(error.body);
throw new Error(
`Create User Request failed. Status: ${error.status} Request: ${JSON.stringify(
req,
)} Response: ${respBody}`,
);
});
return _.merge(req, userResp);
}

/**
* Edits a user with the given parameters via the API.
* @param {number} id - the ID of the user to modify.
* @param {V1PatchUser} user - the user request with the config for the new user.
* See apiUser.newRandom() for the default config.
* @returns {Promise<V1User>} Representation of the modified user. Note that this
* does not include some fields like password.
*/
async patchUser(id: number, user: V1PatchUser): Promise<V1User> {
const userResp = await (await this.startUserRequest())
.patchUser(id, user)
.catch(async function (error) {
const respBody = await streamConsumers.text(error.body);
throw new Error(
`Patch User Request failed. Status: ${error.status} Request: ${JSON.stringify(
user,
)} Response: ${respBody}`,
);
});
return userResp.user;
}
}
42 changes: 35 additions & 7 deletions webui/react/src/e2e/fixtures/global-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test as base, Page } from '@playwright/test';

import { ApiAuthFixture } from './api.auth.fixture';
import { ApiUserFixture } from './api.user.fixture';
import { AuthFixture } from './auth.fixture';
import { DevFixture } from './dev.fixture';
import { UserFixture } from './user.fixture';
Expand All @@ -9,31 +10,58 @@ type CustomFixtures = {
dev: DevFixture;
auth: AuthFixture;
apiAuth: ApiAuthFixture;
backgroundApiAuth: ApiAuthFixture;
user: UserFixture;
apiUser: ApiUserFixture;
backgroundApiUser: ApiUserFixture;
authedPage: Page;
};

// https://playwright.dev/docs/test-fixtures
export const test = base.extend<CustomFixtures>({
// get the auth but allow yourself to log in through the api manually.
apiAuth: async ({ playwright, browser }, use) => {
const apiAuth = new ApiAuthFixture(playwright.request, browser);
apiAuth: async ({ playwright, browser, dev, baseURL }, use) => {
await dev.setServerAddress();
const apiAuth = new ApiAuthFixture(playwright.request, browser, baseURL, dev.page);
await apiAuth.login();
await use(apiAuth);
},

apiUser: async ({ apiAuth }, use) => {
const apiUser = new ApiUserFixture(apiAuth);
await use(apiUser);
},

auth: async ({ page }, use) => {
const auth = new AuthFixture(page);
await use(auth);
},

// get a page already logged in
authedPage: async ({ playwright, browser, dev }, use) => {
await dev.setServerAddress();
const apiAuth = new ApiAuthFixture(playwright.request, browser, dev.page);
await apiAuth.login();
// get the existing page but with auth cookie already logged in
authedPage: async ({ apiAuth }, use) => {
await use(apiAuth.page);
},

/**
* Does not require the pre-existing Playwright page and does not login so this can be called in beforeAll.
* Generally use another api fixture instead if you want to call an api. If you just want a logged-in page,
* use apiAuth in beforeEach().
*/
backgroundApiAuth: async ({ playwright, browser, baseURL }, use) => {
const backgroundApiAuth = new ApiAuthFixture(playwright.request, browser, baseURL);
await use(backgroundApiAuth);
},
/**
* Allows calling the user api without a page so that it can run in beforeAll(). You will need to get a bearer
* token by calling backgroundApiUser.apiAuth.login(). This will also provision a page in the background which
* will be disposed of logout(). Before using the page,you need to call dev.setServerAddress() manually and
* then login() again, since setServerAddress logs out as a side effect.
*/
backgroundApiUser: async ({ backgroundApiAuth }, use) => {
const backgroundApiUser = new ApiUserFixture(backgroundApiAuth);
await use(backgroundApiUser);
},

dev: async ({ page }, use) => {
const dev = new DevFixture(page);
await use(dev);
Expand Down
Loading

0 comments on commit 0599d0e

Please sign in to comment.