Skip to content

Commit

Permalink
First e2e tests for plugin initialization (#4465)
Browse files Browse the repository at this point in the history
# What this PR does
- Fix extension (IRM settings) to work with backend proxy approach
- Create e2e tests that verifies that newly created users can use OnCall
/ OnCall extensions right away without any action from the Admin
- Create an initial draft for plugin configuration page
- More cleanup


<!--
*Note*: if you have more than one GitHub issue that this PR closes, be
sure to preface
each issue link with a [closing
keyword](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
This ensures that the issue(s) are auto-closed once the PR has been
merged.
-->

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
  • Loading branch information
brojd authored Jun 7, 2024
1 parent 9c5ce13 commit e9664f4
Show file tree
Hide file tree
Showing 22 changed files with 300 additions and 85 deletions.
8 changes: 1 addition & 7 deletions grafana-plugin/e2e-tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,9 @@ import {
GRAFANA_VIEWER_USERNAME,
IS_CLOUD,
IS_OPEN_SOURCE,
OrgRole,
} from './utils/constants';

enum OrgRole {
None = 'None',
Viewer = 'Viewer',
Editor = 'Editor',
Admin = 'Admin',
}

type UserCreationSettings = {
adminAuthedRequest: APIRequestContext;
role: OrgRole;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PLUGIN_ID } from 'utils/consts';

import { test, expect } from '../fixtures';
import { goToGrafanaPage } from '../utils/navigation';

test.describe('Plugin configuration', () => {
test('Admin user can see currently applied URL', async ({ adminRolePage: { page } }) => {
const urlInput = page.getByTestId('oncall-api-url-input');

await goToGrafanaPage(page, `/plugins/${PLUGIN_ID}`);
const currentlyAppliedURL = await urlInput.inputValue();

expect(currentlyAppliedURL).toBe('http://oncall-dev-engine:8080');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { test, expect } from '../fixtures';
import { OrgRole } from '../utils/constants';
import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation';
import { createGrafanaUser, loginAndWaitTillGrafanaIsLoaded } from '../utils/users';

test.describe('Plugin initialization', () => {
test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page }, browser }) => {
// Create new viewer user and login as new user
const USER_NAME = `viewer-${new Date().getTime()}`;
await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Viewer });

// Create new browser context to act as new user
const viewerUserContext = await browser.newContext();
const viewerUserPage = await viewerUserContext.newPage();

await loginAndWaitTillGrafanaIsLoaded({ page: viewerUserPage, username: USER_NAME });

// Start watching for HTTP responses
const networkResponseStatuses: number[] = [];
viewerUserPage.on('requestfinished', async (request) =>
networkResponseStatuses.push((await request.response()).status())
);

// Go to OnCall and assert that none of the requests failed
await goToOnCallPage(viewerUserPage, 'alert-groups');
await viewerUserPage.waitForLoadState('networkidle');
const numberOfFailedRequests = networkResponseStatuses.filter(
(status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3'))
).length;
expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled
});

test('Extension registered by OnCall plugin works for new editor user right away', async ({
adminRolePage: { page },
browser,
}) => {
// Create new editor user
const USER_NAME = `editor-${new Date().getTime()}`;
await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Editor });
await page.waitForLoadState('networkidle');

// Create new browser context to act as new user
const editorUserContext = await browser.newContext();
const editorUserPage = await editorUserContext.newPage();

await loginAndWaitTillGrafanaIsLoaded({ page: editorUserPage, username: USER_NAME });

// Start watching for HTTP responses
const networkResponseStatuses: number[] = [];
editorUserPage.on('requestfinished', async (request) =>
networkResponseStatuses.push((await request.response()).status())
);

// Go to profile -> IRM tab where OnCall plugin extension is registered and assert that none of the requests failed
await goToGrafanaPage(editorUserPage, '/profile?tab=irm');
await editorUserPage.waitForLoadState('networkidle');
const numberOfFailedRequests = networkResponseStatuses.filter(
(status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3'))
).length;
expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled

// ...as well as that user sees content of the extension
const extensionContentText = editorUserPage.getByText('Please connect Grafana Cloud OnCall to use the mobile app');
await extensionContentText.waitFor();
await expect(extensionContentText).toBeVisible();
});
});
27 changes: 17 additions & 10 deletions grafana-plugin/e2e-tests/users/usersActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ import semver from 'semver';

import { test, expect } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
import { viewUsers, accessProfileTabs } from '../utils/users';
import { verifyThatUserCanViewOtherUsers, accessProfileTabs } from '../utils/users';

test.describe('Users screen actions', () => {
test("Admin is allowed to edit other users' profile", async ({ adminRolePage: { page } }) => {
await goToOnCallPage(page, 'users');
await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(3);
const editableUsers = page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false });
await editableUsers.first().waitFor();
const editableUsersCount = await editableUsers.count();
expect(editableUsersCount).toBeGreaterThan(1);
});

test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => {
await viewUsers(page);
await verifyThatUserCanViewOtherUsers(page);
});

test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => {
await viewUsers(page, false);
await verifyThatUserCanViewOtherUsers(page, false);
});

test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => {
const { page } = viewerRolePage;
const tabsToCheck = ['tab-phone-verification', 'tab-slack', 'tab-telegram'];

console.log(process.env.CURRENT_GRAFANA_VERSION);

// After 10.3 it's been moved to global user profile
if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) {
tabsToCheck.unshift('tab-mobile-app');
Expand All @@ -33,7 +34,7 @@ test.describe('Users screen actions', () => {
});

test('Editor is allowed to view the list of users', async ({ editorRolePage }) => {
await viewUsers(editorRolePage.page);
await verifyThatUserCanViewOtherUsers(editorRolePage.page);
});

test("Editor cannot view other users' data", async ({ editorRolePage }) => {
Expand All @@ -43,8 +44,10 @@ test.describe('Users screen actions', () => {
await page.getByTestId('users-email').and(page.getByText('editor')).waitFor();

await expect(page.getByTestId('users-email').and(page.getByText('editor'))).toHaveCount(1);
await expect(page.getByTestId('users-email').and(page.getByText('******'))).toHaveCount(2);
await expect(page.getByTestId('users-phone-number').and(page.getByText('******'))).toHaveCount(2);
const maskedEmailsCount = await page.getByTestId('users-email').and(page.getByText('******')).count();
expect(maskedEmailsCount).toBeGreaterThan(1);
const maskedPhoneNumbersCount = await page.getByTestId('users-phone-number').and(page.getByText('******')).count();
expect(maskedPhoneNumbersCount).toBeGreaterThan(1);
});

test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => {
Expand All @@ -57,7 +60,11 @@ test.describe('Users screen actions', () => {
test("Editor is not allowed to edit other users' profile", async ({ editorRolePage: { page } }) => {
await goToOnCallPage(page, 'users');
await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(1);
await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: true })).toHaveCount(2);
const usersCountWithDisabledEdit = await page
.getByTestId('users-table')
.getByRole('button', { name: 'Edit', disabled: true })
.count();
expect(usersCountWithDisabledEdit).toBeGreaterThan(1);
});

test('Search updates the table view', async ({ adminRolePage }) => {
Expand Down
7 changes: 7 additions & 0 deletions grafana-plugin/e2e-tests/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc

export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true';
export const IS_CLOUD = !IS_OPEN_SOURCE;

export enum OrgRole {
None = 'None',
Viewer = 'Viewer',
Editor = 'Editor',
Admin = 'Admin',
}
5 changes: 3 additions & 2 deletions grafana-plugin/e2e-tests/utils/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ClickButtonArgs = {
buttonText: string | RegExp;
// if provided, use this Locator as the root of our search for the button
startingLocator?: Locator;
exact?: boolean;
};

export const fillInInput = (page: Page, selector: string, value: string) => page.fill(selector, value);
Expand All @@ -34,9 +35,9 @@ export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: stri

export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`);

export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise<void> => {
export const clickButton = async ({ page, buttonText, startingLocator, exact }: ClickButtonArgs): Promise<void> => {
const baseLocator = startingLocator || page;
await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click();
await baseLocator.getByRole('button', { name: buttonText, disabled: false, exact }).click();
};

/**
Expand Down
47 changes: 44 additions & 3 deletions grafana-plugin/e2e-tests/utils/users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Page, expect } from '@playwright/test';

import { goToOnCallPage } from './navigation';
import { OrgRole } from './constants';
import { clickButton } from './forms';
import { goToGrafanaPage, goToOnCallPage } from './navigation';

export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) {
await goToOnCallPage(page, 'users');
Expand Down Expand Up @@ -30,16 +32,55 @@ export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: b
}
}

export async function viewUsers(page: Page, isAllowedToView = true): Promise<void> {
export async function verifyThatUserCanViewOtherUsers(page: Page, isAllowedToView = true): Promise<void> {
await goToOnCallPage(page, 'users');

if (isAllowedToView) {
const usersTable = page.getByTestId('users-table');
await usersTable.getByRole('row').nth(1).waitFor();
await expect(usersTable.getByRole('row')).toHaveCount(4);
const usersCount = await page.getByTestId('users-table').getByRole('row').count();
expect(usersCount).toBeGreaterThan(1);
} else {
await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText(
/You are missing the .* to be able to view OnCall users/
);
}
}

export const createGrafanaUser = async ({
page,
username,
role = OrgRole.Viewer,
}: {
page: Page;
username: string;
role?: OrgRole;
}): Promise<void> => {
await goToGrafanaPage(page, '/admin/users');
await page.getByRole('link', { name: 'New user' }).click();
await page.getByLabel('Name *').fill(username);
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password *').fill(username);
await clickButton({ page, buttonText: 'Create user' });

if (role !== OrgRole.Viewer) {
await clickButton({ page, buttonText: 'Change role' });
await page
.locator('div')
.filter({ hasText: /^Viewer$/ })
.nth(1)
.click();
await page.getByText(new RegExp(role)).click();
await clickButton({ page, buttonText: 'Save' });
}
};

export const loginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => {
await goToGrafanaPage(page, '/login');
await page.getByLabel('Email or username').fill(username);
await page.getByLabel(/Password/).fill(username);
await clickButton({ page, buttonText: 'Log in' });

await page.getByText('Welcome to Grafana').waitFor();
await page.waitForLoadState('networkidle');
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { locationUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafan
import { IconName, Menu } from '@grafana/ui';

import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
import { PLUGIN_ID } from 'utils/consts';
import { truncateTitle } from 'utils/string';

type Props = {
Expand Down Expand Up @@ -68,7 +69,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
icon: 'fire',
category: 'Incident',
title: 'Declare incident',
pluginId: 'grafana-oncall-app',
pluginId: PLUGIN_ID,
} as Partial<PluginExtensionLink>,
])}
</Menu.Group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { RootStore, rootStore as store } from 'state/rootStore';
import { UserActions } from 'utils/authorization/authorization';
import { useInitializePlugin } from 'utils/hooks';
import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils';

import styles from './MobileAppConnection.module.scss';
Expand Down Expand Up @@ -364,10 +365,13 @@ function QRLoading() {

export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => {
const { userStore } = store;
const { isInitialized } = useInitializePlugin();

useEffect(() => {
loadData();
}, []);
if (isInitialized) {
loadData();
}
}, [isInitialized]);

const loadData = async () => {
if (!store.isBasicDataLoaded) {
Expand All @@ -379,7 +383,7 @@ export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => {
}
};

if (store.isBasicDataLoaded && userStore.currentUserPk) {
if (isInitialized && store.isBasicDataLoaded && userStore.currentUserPk) {
return <MobileAppConnection userPk={userStore.currentUserPk} />;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,54 @@
import React from 'react';

export const PluginConfigPage = () => <>plugin config page</>;
import { PluginConfigPageProps, PluginMeta } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Field, HorizontalGroup, Input } from '@grafana/ui';
import { observer } from 'mobx-react-lite';
import { Controller, useForm } from 'react-hook-form';
import { OnCallPluginMetaJSONData } from 'types';

import { Button } from 'components/Button/Button';
import { getOnCallApiUrl } from 'utils/consts';
import { validateURL } from 'utils/string';

type PluginConfigFormValues = {
onCallApiUrl: string;
};

export const PluginConfigPage = observer((props: PluginConfigPageProps<PluginMeta<OnCallPluginMetaJSONData>>) => {
const { handleSubmit, control, formState } = useForm<PluginConfigFormValues>({
mode: 'onChange',
defaultValues: { onCallApiUrl: getOnCallApiUrl(props.plugin.meta) },
});

const onSubmit = (values: PluginConfigFormValues) => {
// eslint-disable-next-line no-console
console.log(values);
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name={'onCallApiUrl'}
control={control}
rules={{ required: 'OnCall API URL is required', validate: validateURL }}
render={({ field }) => (
<Field
key={'Name'}
label={'OnCall API URL'}
invalid={Boolean(formState.errors.onCallApiUrl)}
error={formState.errors.onCallApiUrl?.message}
>
<Input {...field} placeholder={'OnCall API URL'} data-testid="oncall-api-url-input" />
</Field>
)}
/>
<HorizontalGroup>
<Button type="submit" disabled={!formState.isValid}>
Test & Save connection
</Button>
{config.featureToggles.externalServiceAccounts && <Button variant="secondary">Recreate service account</Button>}
</HorizontalGroup>
</form>
);
});
Loading

0 comments on commit e9664f4

Please sign in to comment.