Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ export class E2EApiClient {
readonly environment: E2EEnvironment
) {}

async createInvitedUser(token: string, organizationId: string, name: string, email: string, password: string): Promise<string> {
await this.inviteOrganizationUser(token, organizationId, email);
const response = await this.request.get(this.url(`organizations/${organizationId}`), {
headers: this.authHeaders(token)
});
await expectStatus(response, [200], 'get organization invitation');
const organization = (await readJson(response)) as { invites?: { email_address: string; token: string }[] };
const invitation = organization.invites?.find((invite) => invite.email_address.toLowerCase() === email.toLowerCase());
if (!invitation?.token) {
throw new Error('Could not find the generated test user invitation');
}

return await this.signup(name, email, password, invitation.token);
}

async createOrganization(token: string, name: string): Promise<E2EOrganization> {
const response = await this.request.post(this.url('organizations'), {
data: { name },
Expand Down Expand Up @@ -285,10 +300,11 @@ export class E2EApiClient {
throw new Error(`Timed out waiting for ${path} email sent to ${email}`);
}

async signup(name: string, email: string, password: string): Promise<string> {
async signup(name: string, email: string, password: string, inviteToken?: string): Promise<string> {
const response = await this.request.post(this.url('auth/signup'), {
data: {
email,
invite_token: inviteToken,
name,
password
}
Expand Down
74 changes: 74 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/fixtures/organization-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { runCleanupStep, throwIfCleanupFailed } from '../support/cleanup';
import { test as base, E2E_TEST_PASSWORD, type E2ESecondaryOrganization } from './e2e-test';

interface ImpersonatedOrganization extends E2ESecondaryOrganization {
ownerToken: string;
}

export const test = base.extend<{ impersonatedOrganization: ImpersonatedOrganization }>({
impersonatedOrganization: async ({ e2eApi, e2eScenario }, use) => {
const email = `impersonation-${e2eScenario.run}@exceptionless.test`.toLowerCase();
const organizationName = `Impersonated Organization ${e2eScenario.run}`;
const projectName = `Impersonated Project ${e2eScenario.run}`;
let ownerToken: string | undefined;
let organizationId: string | undefined;
let projectId: string | undefined;

try {
ownerToken = await e2eApi.createInvitedUser(
e2eScenario.userToken,
e2eScenario.organizationId,
'Impersonated organization owner',
email,
E2E_TEST_PASSWORD
);
await e2eApi.deleteOrganizationUser(e2eScenario.userToken, e2eScenario.organizationId, email);
await e2eApi.waitForOrganizationNotListed(ownerToken, e2eScenario.organizationId);
const organization = await e2eApi.createOrganization(ownerToken, organizationName);
organizationId = organization.id;
await e2eApi.waitForOrganizationListed(ownerToken, organization.id);
const project = await e2eApi.createProject(ownerToken, organization.id, projectName);
projectId = project.id;
const projectToken = await e2eApi.getProjectDefaultToken(ownerToken, project.id);

await use({
message: `Impersonated event ${e2eScenario.run}`,
organizationId,
organizationName,
ownerToken,
projectId,
projectName,
projectToken: projectToken.id,
referenceId: e2eScenario.referenceId
});
} finally {
const errors: Error[] = [];
if (!ownerToken) {
await runCleanupStep(errors, 'recover impersonated owner session', async () => {
ownerToken = await e2eApi.loginIfExists(email, E2E_TEST_PASSWORD);
});
}
if (ownerToken && projectId) {
await runCleanupStep(errors, 'delete impersonated project', async () => {
await e2eApi.deleteProject(ownerToken!, projectId!);
await e2eApi.waitForProjectDeleted(ownerToken!, projectId!);
});
}
if (ownerToken && organizationId) {
await runCleanupStep(errors, 'delete impersonated organization', async () => {
await e2eApi.deleteOrganization(ownerToken!, organizationId!);
await e2eApi.waitForOrganizationDeleted(ownerToken!, organizationId!);
});
}
if (ownerToken) {
await runCleanupStep(errors, 'delete impersonated owner', async () => {
await e2eApi.deleteCurrentUser(ownerToken!);
await e2eApi.waitForCurrentUserDeleted(ownerToken!);
});
}
throwIfCleanupFailed(errors);
}
}
});

export { expect } from './e2e-test';
13 changes: 13 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/support/web-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export async function dispatchWebSocketMessages(page: Page, messages: unknown[])
}, messages);
}

export async function getWebSocketConnectionState(page: Page): Promise<{ activeOrganizationIds: (null | string)[]; created: number; pending: number }> {
return await page.evaluate(() => {
const sockets = ((window as TrackedWebSocketWindow).__exceptionlessE2EWebSockets ?? []).filter((socket) => socket.url.includes('/api/v2/push'));
return {
activeOrganizationIds: sockets
.filter((socket) => socket.readyState !== WebSocket.CLOSED)
.map((socket) => new URL(socket.url).searchParams.get('organization_id')),
created: sockets.length,
pending: sockets.filter((socket) => socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.CLOSING).length
};
});
}

export async function installWebSocketTestHarness(page: Page): Promise<void> {
await page.addInitScript(() => {
const trackedWindow = window as TrackedWebSocketWindow;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { expect, test } from '../fixtures/organization-test';

test.afterEach(async ({ page }) => {
await page.unrouteAll({ behavior: 'wait' });
});

for (const iconState of ['missing', 'broken']) {
test(`organization avatars handle an impersonated organization with a ${iconState} icon without a reload`, async ({
e2eScenario,
impersonatedOrganization,
page
}, testInfo) => {
// Keep image inputs deterministic while exercising the real switcher and avatar lifecycle.
await page.route(/\/api\/v2\/organizations(?:\/[a-f0-9]{24})?(?:\?.*)?$/, async (route) => {
const response = await route.fetch();
const data = await response.json();
const withIcon = (organization: { icon_url?: string; id: string }) => {
if (organization.id === e2eScenario.organizationId) {
return { ...organization, icon_url: '/next/favicon.png' };
}
if (organization.id === impersonatedOrganization.organizationId && iconState === 'broken') {
return { ...organization, icon_url: '/next/e2e-unavailable-organization-icon.png' };
}
return organization;
};
await route.fulfill({ json: Array.isArray(data) ? data.map(withIcon) : withIcon(data), response });
});
if (iconState === 'broken') {
await page.route('**/e2e-unavailable-organization-icon.png', async (route) => {
await route.fulfill({ body: '', status: 404 });
});
}
await page.goto(`/next/organization/${e2eScenario.organizationId}/manage`);
await expect(page.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first()).toBeVisible();

const organizationAvatar = page.getByTitle('Organization Avatar', { exact: true }).filter({ visible: true });
await expect(organizationAvatar.locator('img')).toBeVisible();
await expect.poll(() => organizationAvatar.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);

await page.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first().click();
await page.getByRole('menuitem', { name: 'Impersonate Organization' }).click();
const dialog = page.getByRole('dialog', { name: 'Impersonate Organization' });
await dialog.getByPlaceholder('Search by name or Id...').fill(impersonatedOrganization.organizationId);
await dialog.getByRole('button').filter({ hasText: impersonatedOrganization.organizationName }).click();
await dialog.getByRole('button', { exact: true, name: 'Impersonate' }).click();
await expect(dialog).toBeHidden();
await expect(
page
.getByRole('button')
.filter({ hasText: impersonatedOrganization.organizationName })
.filter({ hasText: 'Impersonating' })
.filter({ visible: true })
).toBeVisible();
if (iconState === 'broken') {
await expect(organizationAvatar).toHaveAttribute('data-status', 'error');
}
await expect(organizationAvatar.getByText('IO', { exact: true })).toBeVisible();
await expect(organizationAvatar.locator('img')).toBeHidden();
await page.screenshot({ path: testInfo.outputPath('impersonated-organization-avatar.png') });

await page.getByRole('button').filter({ hasText: impersonatedOrganization.organizationName }).filter({ visible: true }).first().click();
await page.getByRole('menuitem', { name: 'Stop Impersonating' }).click();
// Stop Impersonating selects the first membership, which need not be this test's organization.
await page.getByTitle('Organization Avatar', { exact: true }).filter({ visible: true }).click();
await page.getByRole('menuitem').filter({ hasText: e2eScenario.organizationName }).click();
await expect(organizationAvatar.locator('img')).toBeVisible();
await expect.poll(() => organizationAvatar.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
});
}

test.describe('single organization avatars', () => {
test.use({ e2eUseGeneratedUser: true });

test('removing a loaded icon restores initials in the sidebar and settings', async ({ e2eScenario, page }) => {
await page.goto(`/next/organization/${e2eScenario.organizationId}/manage`);
await expect(page.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first()).toBeVisible();
await page.getByLabel('Upload organization icon').setInputFiles('static/favicon.png');

const organizationAvatars = page.getByTitle('Organization Icon', { exact: true }).filter({ visible: true });
await expect(organizationAvatars).toHaveCount(2);
for (const avatar of await organizationAvatars.all()) {
await expect(avatar.locator('img')).toBeVisible();
await expect.poll(() => avatar.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
}

await page.getByRole('button', { name: 'Remove custom organization icon' }).click();
await expect(page.getByRole('button', { name: 'Remove custom organization icon' })).toBeHidden();
for (const avatar of await organizationAvatars.all()) {
await expect(avatar.getByText('EP', { exact: true })).toBeVisible();
}
});
});
Loading
Loading