diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 8b3f0f6eaf..f033a62c19 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -41,6 +41,21 @@ export class E2EApiClient { readonly environment: E2EEnvironment ) {} + async createInvitedUser(token: string, organizationId: string, name: string, email: string, password: string): Promise { + 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 { const response = await this.request.post(this.url('organizations'), { data: { name }, @@ -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 { + async signup(name: string, email: string, password: string, inviteToken?: string): Promise { const response = await this.request.post(this.url('auth/signup'), { data: { email, + invite_token: inviteToken, name, password } diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/organization-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/organization-test.ts new file mode 100644 index 0000000000..d727789d32 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/organization-test.ts @@ -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'; diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/web-socket.ts b/src/Exceptionless.Web/ClientApp/e2e/support/web-socket.ts index ef37b3de13..b9d32550b2 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/web-socket.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/web-socket.ts @@ -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 { await page.addInitScript(() => { const trackedWindow = window as TrackedWebSocketWindow; diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/organization-avatar.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-avatar.e2e.ts new file mode 100644 index 0000000000..2ebce2ae9b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-avatar.e2e.ts @@ -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(); + } + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/organization-context.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-context.e2e.ts new file mode 100644 index 0000000000..8d78033027 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-context.e2e.ts @@ -0,0 +1,146 @@ +import { expect, test } from '../fixtures/organization-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { getVisibleText } from '../support/page-helpers'; +import { createSessionEvent } from '../support/synthetic-event'; + +test('account menu organization actions target the impersonated organization', async ({ e2eApi, e2eScenario, impersonatedOrganization, page }) => { + const currentUser = await e2eApi.getCurrentUser(e2eScenario.userToken); + await page.goto(`/next/organization/${impersonatedOrganization.organizationId}/manage`); + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + await page.getByRole('button').filter({ hasText: currentUser!.email_address! }).click(); + await expect(page.getByRole('menuitem', { exact: true, name: 'Manage Organization' })).toBeVisible(); + await page.getByRole('menuitem', { exact: true, name: 'Billing' }).click(); + await expect(page).toHaveURL(new RegExp(`/organization/${impersonatedOrganization.organizationId}/billing$`)); + await page.getByRole('button').filter({ hasText: currentUser!.email_address! }).click(); + await page.getByRole('menuitem', { exact: true, name: 'Manage Organization' }).click(); + await expect(page).toHaveURL(new RegExp(`/organization/${impersonatedOrganization.organizationId}/manage$`)); +}); + +test('project notifications use the selected organization, including impersonation', async ({ + e2eScenario, + e2eSecondaryOrganization, + impersonatedOrganization, + page +}) => { + const projectPicker = page.locator('[data-slot="select-trigger"]'); + await page.goto(`/next/account/notifications?project=${e2eScenario.projectId}`); + await expect(projectPicker).toHaveText(e2eScenario.projectName); + await projectPicker.click(); + await expect(page.getByRole('option', { name: e2eSecondaryOrganization.projectName })).toBeHidden(); + await page.keyboard.press('Escape'); + + await page.goto(`/next/organization/${impersonatedOrganization.organizationId}/manage`); + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + await page.goto(`/next/account/notifications?project=${e2eScenario.projectId}`); + await expect(projectPicker).toHaveText(impersonatedOrganization.projectName); + await projectPicker.click(); + await expect(page.getByRole('option', { name: e2eScenario.projectName })).toBeHidden(); + await page.keyboard.press('Escape'); + await page.reload(); + await expect(projectPicker).toHaveText(impersonatedOrganization.projectName); +}); + +test('global administrators can remain in an impersonated organization without memberships', async ({ impersonatedOrganization, page }) => { + await page.route('**/api/v2/users/me', async (route) => { + const response = await route.fetch(); + const user = await response.json(); + await route.fulfill({ json: { ...user, organization_ids: [] }, response }); + }); + await page.route(/\/api\/v2\/organizations(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: [] }); + }); + await page.goto(`/next/organization/${impersonatedOrganization.organizationId}/manage`); + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + await expect(page.getByRole('button').filter({ hasText: impersonatedOrganization.organizationName }).filter({ hasText: 'Impersonating' })).toBeVisible(); + await page.goto('/next/project/list'); + await expect(getVisibleText(page, impersonatedOrganization.projectName)).toBeVisible(); + await page.reload(); + await expect(getVisibleText(page, impersonatedOrganization.projectName)).toBeVisible(); +}); + +test('impersonated organization changes arrive through the live connection', async ({ e2eApi, impersonatedOrganization, page }) => { + await page.goto(`/next/organization/${impersonatedOrganization.organizationId}/manage`); + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + const receivedOrganizations: string[] = []; + page.on('websocket', (socket) => { + if (!socket.url().includes('/api/v2/push')) { + return; + } + socket.on('framereceived', ({ payload }) => { + const envelope = JSON.parse(String(payload)) as { message?: { organization_id?: string } }; + if (envelope.message?.organization_id) { + receivedOrganizations.push(envelope.message.organization_id); + } + }); + }); + const socketConnected = page.waitForEvent('websocket', (socket) => socket.url().includes('/api/v2/push')); + await page.reload(); + await socketConnected; + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + await seedRepresentativeEvent(e2eApi, impersonatedOrganization.ownerToken, impersonatedOrganization); + await expect.poll(() => receivedOrganizations, { timeout: 15_000 }).toContain(impersonatedOrganization.organizationId); +}); + +test('dashboards and project filters use the impersonated organization', async ({ e2eApi, e2eScenario, impersonatedOrganization, page }) => { + await Promise.all([ + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eScenario), + seedRepresentativeEvent(e2eApi, impersonatedOrganization.ownerToken, impersonatedOrganization) + ]); + await page.goto(`/next/organization/${impersonatedOrganization.organizationId}/manage`); + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + + for (const path of ['event', 'stack', 'stream']) { + await page.goto(`/next/${path}?time=all`); + await expect(getVisibleText(page, impersonatedOrganization.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eScenario.message)).toBeHidden(); + } + + await page.goto('/next/event?time=all'); + await page.getByRole('button', { name: 'Manage filters' }).click(); + await page.getByPlaceholder('Search...').fill('Project'); + await page.getByRole('option', { exact: true, name: 'Project' }).click(); + await expect(page.getByRole('option', { name: impersonatedOrganization.projectName })).toBeVisible(); + await expect(page.getByRole('option', { name: e2eScenario.projectName })).toBeHidden(); +}); + +test('Sessions use the impersonated organization', async ({ e2eApi, e2eScenario, impersonatedOrganization, page }) => { + for (const scope of [e2eScenario, { ...impersonatedOrganization, userToken: impersonatedOrganization.ownerToken }]) { + await e2eApi.submitEvent( + scope.projectId, + scope.projectToken, + createSessionEvent({ + identity: `${scope.projectId}@exceptionless.test`, + name: scope.projectName, + sessionId: scope.referenceId + }) + ); + await e2eApi.pollForEventByReference(scope.userToken, scope.projectId, scope.referenceId); + } + await page.goto(`/next/organization/${impersonatedOrganization.organizationId}/manage`); + await expect(page.getByRole('heading', { name: `${impersonatedOrganization.organizationName} Settings` })).toBeVisible(); + await page.goto('/next/sessions?time=all'); + await expect(getVisibleText(page, impersonatedOrganization.projectName)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeHidden(); +}); + +test('Event Stream clears old rows when another tab changes the organization', async ({ e2eApi, e2eScenario, impersonatedOrganization, page }) => { + await Promise.all([ + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eScenario), + seedRepresentativeEvent(e2eApi, impersonatedOrganization.ownerToken, impersonatedOrganization) + ]); + await page.goto('/next/stream'); + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + + const organizationTab = await page.context().newPage(); + try { + await organizationTab.goto('/next/status'); + await organizationTab.evaluate( + (organizationId) => window.localStorage.setItem('organization', JSON.stringify(organizationId)), + impersonatedOrganization.organizationId + ); + await expect(getVisibleText(page, impersonatedOrganization.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eScenario.message)).toBeHidden(); + } finally { + await organizationTab.close(); + } +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/organization-reactivity.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-reactivity.e2e.ts new file mode 100644 index 0000000000..f361792225 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-reactivity.e2e.ts @@ -0,0 +1,111 @@ +import type { CDPSession, Page } from '@playwright/test'; + +import { expect, test } from '../fixtures/organization-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { getWebSocketConnectionState, installWebSocketTestHarness } from '../support/web-socket'; + +for (const route of ['project/list', 'stream']) { + test(`${route} keeps organization effects bounded through repeated switches and visibility changes`, async ({ + e2eApi, + e2eScenario, + impersonatedOrganization, + page + }) => { + await Promise.all([ + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eScenario), + seedRepresentativeEvent(e2eApi, impersonatedOrganization.ownerToken, impersonatedOrganization) + ]); + await installWebSocketTestHarness(page); + const runtimeErrors: string[] = []; + page.on('pageerror', (error) => runtimeErrors.push(error.message)); + let listRequests = 0; + page.on('request', (request) => { + if (/\/api\/v2\/organizations\/[^/]+\/(?:projects|events)(?:\/count)?$/.test(new URL(request.url()).pathname)) { + listRequests++; + } + }); + + await page.goto(`/next/${route}`); + await expect(page.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first()).toBeVisible(); + await expect.poll(async () => (await getWebSocketConnectionState(page)).activeOrganizationIds).toEqual([e2eScenario.organizationId]); + const session = await page.context().newCDPSession(page); + const organizationTab = await page.context().newPage(); + try { + await organizationTab.goto('/next/status'); + const listeners = await getListenerCounts(session); + let connectionCount = (await getWebSocketConnectionState(page)).created; + + for (let index = 0; index < 6; index++) { + const selected = index % 2 === 0 ? impersonatedOrganization : e2eScenario; + const requestCount = listRequests; + await organizationTab.evaluate( + (organizationId) => localStorage.setItem('organization', JSON.stringify(organizationId)), + selected.organizationId + ); + await expect(page.getByRole('button').filter({ hasText: selected.organizationName }).filter({ visible: true }).first()).toBeVisible(); + await expect( + page + .getByText(route === 'stream' ? selected.message : selected.projectName) + .filter({ visible: true }) + .first() + ).toBeVisible(); + await expect + .poll(() => getWebSocketConnectionState(page)) + .toEqual({ + activeOrganizationIds: [selected.organizationId], + created: ++connectionCount, + pending: 0 + }); + // Observe beyond a render flush to catch asynchronous request/reconnect loops too. + await page.waitForTimeout(500); + expect((await getWebSocketConnectionState(page)).created).toBe(connectionCount); + expect(listRequests - requestCount).toBeLessThanOrEqual(4); + expect(await getListenerCounts(session)).toEqual(listeners); + } + + for (let index = 0; index < 5; index++) { + await setDocumentHidden(page, true); + await expect.poll(async () => (await getWebSocketConnectionState(page)).activeOrganizationIds).toEqual([]); + await setDocumentHidden(page, false); + await expect + .poll(() => getWebSocketConnectionState(page)) + .toEqual({ + activeOrganizationIds: [e2eScenario.organizationId], + created: ++connectionCount, + pending: 0 + }); + expect(await getListenerCounts(session)).toEqual(listeners); + } + + // Allow the final resume's intentional query invalidation to finish before checking idle stability. + await page.waitForTimeout(1_000); + const settledRequestCount = listRequests; + await page.waitForTimeout(1_000); + expect(listRequests).toBe(settledRequestCount); + expect((await getWebSocketConnectionState(page)).created).toBe(connectionCount); + expect(runtimeErrors).toEqual([]); + } finally { + await organizationTab.close(); + await session.detach(); + } + }); +} + +async function getListenerCounts(session: CDPSession): Promise<{ keydown: number; visibilitychange: number }> { + const response = await session.send('Runtime.evaluate', { + expression: + '({keydown: (getEventListeners(document).keydown ?? []).length, visibilitychange: (getEventListeners(document).visibilitychange ?? []).length})', + includeCommandLineAPI: true, + returnByValue: true + }); + return response.result.value as { keydown: number; visibilitychange: number }; +} + +async function setDocumentHidden(page: Page, hidden: boolean): Promise { + await page.evaluate((nextHidden) => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => nextHidden }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (nextHidden ? 'hidden' : 'visible') }); + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('visibilitychange')); + }, hidden); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-organization-scoping.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-organization-scoping.e2e.ts new file mode 100644 index 0000000000..1ca749b8ef --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-organization-scoping.e2e.ts @@ -0,0 +1,123 @@ +import type { Page } from '@playwright/test'; + +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; +import { runCleanupStep, throwIfCleanupFailed } from '../support/cleanup'; +import { getVisibleText } from '../support/page-helpers'; + +test.describe('member project lists', () => { + test.use({ e2eUseGeneratedUser: true }); + + test('Projects follows the selected organization through switching and reload', async ({ e2eScenario, e2eSecondaryOrganization, page }) => { + await page.goto('/next/project/list'); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeVisible(); + await expect(getVisibleText(page, e2eSecondaryOrganization.projectName)).toBeHidden(); + + await page.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first().click(); + await page.getByRole('menuitem').filter({ hasText: e2eSecondaryOrganization.organizationName }).click(); + await openProjects(page); + + await expect(getVisibleText(page, e2eSecondaryOrganization.projectName)).toBeVisible(); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeHidden(); + + await page.reload(); + await expect(getVisibleText(page, e2eSecondaryOrganization.projectName)).toBeVisible(); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeHidden(); + + await page.getByRole('button').filter({ hasText: e2eSecondaryOrganization.organizationName }).filter({ visible: true }).first().click(); + await page.getByRole('menuitem').filter({ hasText: e2eScenario.organizationName }).click(); + await openProjects(page); + + await expect(getVisibleText(page, e2eScenario.projectName)).toBeVisible(); + await expect(getVisibleText(page, e2eSecondaryOrganization.projectName)).toBeHidden(); + }); +}); + +test('Projects shows the impersonated organization instead of the administrator memberships', async ({ e2eApi, e2eScenario, page }, testInfo) => { + 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; + + await page.goto('/next/project/list'); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeVisible(); + 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(organization.id); + await dialog.getByRole('button').filter({ hasText: organizationName }).click(); + await dialog.getByRole('button', { exact: true, name: 'Impersonate' }).click(); + await expect(dialog).toBeHidden(); + await expect( + page.getByRole('button').filter({ hasText: organizationName }).filter({ hasText: 'Impersonating' }).filter({ visible: true }) + ).toBeVisible(); + await openProjects(page); + + await expect(getVisibleText(page, projectName)).toBeVisible(); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeHidden(); + + await page.reload(); + await expect(getVisibleText(page, projectName)).toBeVisible(); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('impersonated-projects.png') }); + + await page.getByRole('button').filter({ hasText: organizationName }).filter({ visible: true }).first().click(); + await page.getByRole('menuitem').filter({ hasText: e2eScenario.organizationName }).click(); + await openProjects(page); + await expect(getVisibleText(page, e2eScenario.projectName)).toBeVisible(); + await expect(getVisibleText(page, projectName)).toBeHidden(); + } finally { + const cleanupErrors: Error[] = []; + if (!ownerToken) { + await runCleanupStep(cleanupErrors, 'recover impersonated owner session', async () => { + ownerToken = await e2eApi.loginIfExists(email, E2E_TEST_PASSWORD); + }); + } + if (ownerToken && projectId) { + await runCleanupStep(cleanupErrors, 'delete impersonated project', async () => { + await e2eApi.deleteProject(ownerToken!, projectId!); + await e2eApi.waitForProjectDeleted(ownerToken!, projectId!); + }); + } + if (ownerToken && organizationId) { + await runCleanupStep(cleanupErrors, 'delete impersonated organization', async () => { + await e2eApi.deleteOrganization(ownerToken!, organizationId!); + await e2eApi.waitForOrganizationDeleted(ownerToken!, organizationId!); + }); + } + if (ownerToken) { + await runCleanupStep(cleanupErrors, 'delete impersonated owner', async () => { + await e2eApi.deleteCurrentUser(ownerToken!); + await e2eApi.waitForCurrentUserDeleted(ownerToken!); + }); + } + throwIfCleanupFailed(cleanupErrors); + } +}); + +async function openProjects(page: Page): Promise { + const projects = page.getByRole('link', { exact: true, name: 'Projects' }); + if (!(await projects.isVisible())) { + await page.getByRole('button', { exact: true, name: 'Settings' }).click(); + } + await projects.click(); + await expect(page.getByRole('heading', { exact: true, name: 'Projects' })).toBeVisible(); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index 6948cfde0a..bfe98dafcd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts @@ -448,6 +448,7 @@ export function getOrganizationCountQuery(request: GetOrganizationCountRequest) export function getOrganizationEventsQuery(request: GetOrganizationEventsRequest) { return createQuery[]>, ProblemDetails>(() => { const organizationId = request.route.organizationId; + const enabled = !!accessToken.current && !!organizationId && (request.enabled?.() ?? true); const params = request.params ? { ...request.params @@ -455,8 +456,9 @@ export function getOrganizationEventsQuery(request: GetOrganizationEventsRequest : undefined; return { - enabled: () => !!accessToken.current && !!organizationId && (request.enabled?.() ?? true), - placeholderData: keepPreviousData, + enabled, + placeholderData: (previousData, previousQuery) => + retainPreviousOrganizationQueryData(previousData, previousQuery?.queryKey, organizationId, enabled), queryFn: async () => { const client = useFetchClient(); return await client.getJSON[]>(`organizations/${organizationId}/events`, { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte index c686d92e84..fd7da6598c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte @@ -15,9 +15,6 @@ filterChanged(filter); } - // Store the organizationId to prevent loading when switching organizations. - const organizationId = organization.current; - // Create query with conditional enabled - only fetch when dropdown is open const countQuery = getOrganizationCountQuery({ enabled: () => open, @@ -26,7 +23,7 @@ }, route: { get organizationId() { - return organizationId; + return organization.current; } } }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts new file mode 100644 index 0000000000..b35949d6de --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts @@ -0,0 +1,30 @@ +import { organization } from '$features/organizations/context.svelte'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, expect, it, vi } from 'vitest'; + +import TagFacetedFilter from './tag-faceted-filter.test-harness.svelte'; + +vi.mock('$features/events/api.svelte', () => ({ + getOrganizationCountQuery: (request: { route: { organizationId: string | undefined } }) => ({ + get data() { + return { aggregations: { terms_tags: { items: [{ key: `${request.route.organizationId}-tag`, total: 1 }] } } }; + }, + isLoading: false, + isSuccess: true + }) +})); + +afterEach(() => { + organization.current = undefined; +}); + +it('updates the open tag picker when the current organization changes', async () => { + organization.current = 'membership'; + render(TagFacetedFilter); + await waitFor(() => expect(screen.getByRole('option', { name: 'membership-tag' })).toBeTruthy()); + + organization.current = 'impersonated'; + + await waitFor(() => expect(screen.getByRole('option', { name: 'impersonated-tag' })).toBeTruthy()); + expect(screen.queryByRole('option', { name: 'membership-tag' })).toBeNull(); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.test-harness.svelte new file mode 100644 index 0000000000..efab2041ec --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.test-harness.svelte @@ -0,0 +1,10 @@ + + + + {}} filterRemoved={() => {}} open title="Tag" /> + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/organization-query.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/organization-query.svelte.test.ts new file mode 100644 index 0000000000..d1be92beba --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/organization-query.svelte.test.ts @@ -0,0 +1,73 @@ +import type { FetchClientResponse, ProblemDetails } from '@foundatiofx/fetchclient'; + +import { QueryClient, QueryObserver, type QueryObserverOptions } from '@tanstack/svelte-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { EventSummaryModel, SummaryTemplateKeys } from './components/summary/index'; + +import { getOrganizationEventsQuery } from './api.svelte'; + +type EventsResponse = FetchClientResponse[]>; + +const mocks = vi.hoisted(() => ({ + createQuery: vi.fn<(options: () => QueryObserverOptions) => void>(), + getJSON: vi.fn() +})); +vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-token' } })); +vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ + ...(await importOriginal()), + useFetchClient: () => ({ getJSON: mocks.getJSON }) +})); +vi.mock('@tanstack/svelte-query', async (importOriginal) => ({ + ...(await importOriginal()), + createQuery: mocks.createQuery +})); + +describe('organization event query transitions', () => { + let client: QueryClient; + beforeEach(() => { + vi.resetAllMocks(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + afterEach(() => client.clear()); + + it.each(['summary', 'stack_frequent'] as const)('clears previous %s rows while another organization loads', async (mode) => { + let organizationId = 'membership'; + let page = 1; + const firstResponse = { data: [{ id: 'membership-event' }] } as EventsResponse; + const pending = Promise.withResolvers(); + mocks.getJSON.mockResolvedValueOnce(firstResponse).mockReturnValue(pending.promise); + getOrganizationEventsQuery({ + params: { + mode, + get page() { + return page; + } + }, + route: { + get organizationId() { + return organizationId; + } + } + }); + const options = mocks.createQuery.mock.calls[0]![0]; + const observer = new QueryObserver(client, options()); + const stop = observer.subscribe(() => {}); + try { + await vi.waitFor(() => expect(observer.getCurrentResult().data).toBe(firstResponse)); + page = 2; + observer.setOptions(options()); + expect(observer.getCurrentResult().data).toBe(firstResponse); + + organizationId = 'impersonated'; + observer.setOptions(options()); + expect(observer.getCurrentResult().data).toBeUndefined(); + + const nextResponse = { data: [{ id: 'impersonated-event' }] } as EventsResponse; + pending.resolve(nextResponse); + await vi.waitFor(() => expect(observer.getCurrentResult().data).toBe(nextResponse)); + } finally { + stop(); + } + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts index cf35e91986..6278d453c7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts @@ -121,6 +121,7 @@ export interface GetOrganizationProjectsParams { export interface GetOrganizationProjectsRequest { enabled?: () => boolean; params?: GetOrganizationProjectsParams; + refetchInterval?: false | number; route: { organizationId: string | undefined; }; @@ -401,7 +402,8 @@ export function getOrganizationProjectsQuery(request: GetOrganizationProjectsReq { params: request.params } - ] + ], + refetchInterval: request.refetchInterval })); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts index d18b9df698..30eb78ba98 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.svelte.ts @@ -1,4 +1,5 @@ import { DocumentVisibility } from '$shared/document-visibility.svelte'; +import { SvelteURL } from 'svelte/reactivity'; import { accessToken } from '../auth/index.svelte'; @@ -13,6 +14,8 @@ export interface WebSocketClientOptions { * Default: 10000ms (10 seconds) */ connectionTimeout?: number; + /** Current organization for global administrator impersonation notifications. */ + organizationId?: string; /** * Custom reconnection delay calculator * Default uses exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s @@ -129,7 +132,12 @@ export class WebSocketClient { let socket: WebSocket; try { - socket = new WebSocket(`${this.url}?access_token=${this.accessToken}`); + const url = new SvelteURL(this.url); + url.searchParams.set('access_token', this.accessToken!); + if (this._options.organizationId) { + url.searchParams.set('organization_id', this._options.organizationId); + } + socket = new WebSocket(url.toString()); this.ws = socket; this.onConnecting(isReconnect); } catch (error) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts index 62248bdc2d..b2cd395ef3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts @@ -413,6 +413,16 @@ describe('WebSocketClient', () => { }); describe('URL Construction', () => { + it('includes the selected organization in the connection', async () => { + const client = createClient(undefined, { organizationId: 'impersonated-organization' }); + client.connect(); + const connection = await server.connected; + const url = new URL(connection.url); + + expect(url.searchParams.get('organization_id')).toBe('impersonated-organization'); + client.close(); + }); + it('should construct correct WebSocket URL', () => { const client = createClient('/api/v2/push'); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index b4aa794dfd..6bb023f727 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -106,12 +106,14 @@ }) )} > - - {#if activeOrganization.icon_url} - - {/if} - {getInitials(activeOrganization.name)} - + {#key activeOrganization.icon_url} + + {#if activeOrganization.icon_url} + + {/if} + {getInitials(activeOrganization.name)} + + {/key}
Organization {activeOrganization.name} @@ -130,14 +132,16 @@ isImpersonating && 'bg-violet-100 dark:bg-violet-900/30' ]} > - - {#if activeOrganization?.icon_url} - - {/if} - - {getInitials(activeOrganization?.name ?? '?')} - - + {#key activeOrganization?.icon_url} + + {#if activeOrganization?.icon_url} + + {/if} + + {getInitials(activeOrganization?.name ?? '?')} + + + {/key}
{activeOrganization?.name ?? 'Select an organization'} @@ -169,12 +173,14 @@ data-current-organization={organization.id === currentOrganizationId && !isImpersonating ? 'true' : undefined} class="gap-2 p-2" > - - {#if organization.icon_url} - - {/if} - {getInitials(organization.name)} - + {#key organization.icon_url} + + {#if organization.icon_url} + + {/if} + {getInitials(organization.name)} + + {/key} {organization.name} {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index edba341b60..368392f67c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -1,5 +1,4 @@ diff --git a/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs b/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs index b8d40c0fbb..a72e0c2a5a 100644 --- a/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs +++ b/src/Exceptionless.Web/Hubs/MessageBusBrokerMiddleware.cs @@ -2,6 +2,7 @@ using System.Text; using Exceptionless.Core.Extensions; using Exceptionless.Core.Utility; +using Exceptionless.Web.Extensions; namespace Exceptionless.Web.Hubs; @@ -83,7 +84,7 @@ private async Task OnConnected(HttpContext context, WebSocket socket, string con try { - foreach (string organizationId in context.User.GetOrganizationIds()) + foreach (string organizationId in GetSubscribedOrganizationIds(context)) await _connectionMapping.GroupAddAsync(organizationId, connectionId); string? userId = context.User.GetUserId(); @@ -103,7 +104,7 @@ private async Task OnDisconnected(HttpContext context, WebSocket socket, string try { - foreach (string organizationId in context.User.GetOrganizationIds()) + foreach (string organizationId in GetSubscribedOrganizationIds(context)) await _connectionMapping.GroupRemoveAsync(organizationId, connectionId); string? userId = context.User.GetUserId(); @@ -117,6 +118,16 @@ private async Task OnDisconnected(HttpContext context, WebSocket socket, string } } + private static IEnumerable GetSubscribedOrganizationIds(HttpContext context) + { + var organizationIds = context.User.GetOrganizationIds().ToHashSet(StringComparer.Ordinal); + string? impersonatedOrganizationId = context.Request.Query["organization_id"]; + if (!String.IsNullOrWhiteSpace(impersonatedOrganizationId) && context.Request.IsGlobalAdmin()) + organizationIds.Add(impersonatedOrganizationId); + + return organizationIds; + } + private async Task ReceiveAsync(WebSocket socket, Func handleMessage) { var buffer = new ArraySegment(new byte[1024 * 4]); diff --git a/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs b/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs index 441a1555af..6d8fc929e6 100644 --- a/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs +++ b/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs @@ -19,6 +19,7 @@ public TestWebSocket(WebSocketState state = WebSocketState.Open) public WebSocketCloseStatus? RequestedCloseStatus { get; private set; } public string? RequestedCloseStatusDescription { get; private set; } public List SentMessages { get; } = []; + public Func>? OnReceive { get; set; } public override WebSocketCloseStatus? CloseStatus { get; } = WebSocketCloseStatus.NormalClosure; public override string? CloseStatusDescription { get; } = "Closed"; public override string? SubProtocol { get; } = null; @@ -51,7 +52,7 @@ public override void Dispose() { } public override Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken) { - return Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Text, true)); + return OnReceive?.Invoke() ?? Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Text, true)); } public override Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) diff --git a/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs b/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs index 84e8143540..8430a9b16d 100644 --- a/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs +++ b/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs @@ -1,4 +1,7 @@ using System.Net.WebSockets; +using System.Security.Claims; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Messaging.Models; using Exceptionless.Core.Models; using Exceptionless.Core.Utility; @@ -115,6 +118,48 @@ public async Task OnEntityChangedAsync_AuthTokenRemoved_ClosesWebSocketsAndClear } } + [Theory] + [InlineData(true, "impersonated-organization", true)] + [InlineData(false, "impersonated-organization", false)] + [InlineData(true, null, false)] + [InlineData(false, null, false)] + public async Task Invoke_OrganizationSubscription_RequiresGlobalAdminAndCleansUpOnDisconnect(bool isGlobalAdmin, string? requestedOrganizationId, bool shouldReceiveImpersonatedUpdates) + { + // Arrange + const string membershipOrganizationId = "membership-organization"; + const string impersonatedOrganizationId = "impersonated-organization"; + var identity = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, "subscription-user"), + new Claim(IdentityUtils.OrganizationIdsClaim, membershipOrganizationId), + new Claim(ClaimTypes.Role, isGlobalAdmin ? AuthorizationRoles.GlobalAdmin : AuthorizationRoles.User) + ], IdentityUtils.UserAuthenticationType); + var socket = new TestWebSocket(); + var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; + context.Request.Path = "/api/v2/push"; + if (requestedOrganizationId is not null) + context.Request.QueryString = QueryString.Create("organization_id", requestedOrganizationId); + context.Features.Set(new TestWebSocketFeature(socket)); + var middleware = new MessageBusBrokerMiddleware(_ => Task.CompletedTask, _connectionManager, _connectionMapping, GetService>()); + string[] membershipConnections = []; + string[] impersonatedConnections = []; + socket.OnReceive = async () => + { + membershipConnections = [.. await _connectionMapping.GetGroupConnectionsAsync(membershipOrganizationId)]; + impersonatedConnections = [.. await _connectionMapping.GetGroupConnectionsAsync(impersonatedOrganizationId)]; + return new WebSocketReceiveResult(0, WebSocketMessageType.Close, true, WebSocketCloseStatus.NormalClosure, "Closed"); + }; + + // Act + await middleware.Invoke(context); + + // Assert + Assert.Single(membershipConnections); + Assert.Equal(shouldReceiveImpersonatedUpdates ? 1 : 0, impersonatedConnections.Length); + Assert.Empty(await _connectionMapping.GetGroupConnectionsAsync(membershipOrganizationId)); + Assert.Empty(await _connectionMapping.GetGroupConnectionsAsync(impersonatedOrganizationId)); + Assert.Empty(await _connectionMapping.GetUserIdConnectionsAsync("subscription-user")); + } + [Fact] public async Task OnEntityChangedAsync_NonAuthTokenRemoved_DoesNotCloseWebSockets() { diff --git a/tests/http/websockets.http b/tests/http/websockets.http new file mode 100644 index 0000000000..39c2fab6ef --- /dev/null +++ b/tests/http/websockets.http @@ -0,0 +1,27 @@ +@apiUrl = http://localhost:7110/api/v2 +@email = admin@exceptionless.test +@password = tester +@organizationId = 537650f3b77efe23a47914f3 + +### Authenticate before opening the WebSocket connection +# @name login +POST {{apiUrl}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +### Organization notifications (requires a client that supports WebSocket upgrade) +# The optional organization_id adds an impersonated organization subscription for +# global administrators. Membership subscriptions and notification formats remain +# unchanged. Other users cannot add subscriptions outside their memberships. +# Reconnect when the selected organization changes. Omitting organization_id keeps +# the existing membership-only behavior. +GET {{apiUrl}}/push?organization_id={{organizationId}} +Authorization: Bearer {{login.response.body.$.token}} +Connection: Upgrade +Upgrade: websocket +Sec-WebSocket-Version: 13 +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==