From a2b930c119df56d4663b814727cf6090d6b40633 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 12 Sep 2026 23:06:21 -0500 Subject: [PATCH 1/2] Fix current organization context and impersonation --- .../e2e/fixtures/organization-test.ts | 66 ++++++++ .../e2e/tests/organization-avatar.e2e.ts | 79 ++++++++++ .../e2e/tests/organization-context.e2e.ts | 146 ++++++++++++++++++ .../tests/project-organization-scoping.e2e.ts | 115 ++++++++++++++ .../src/lib/features/events/api.svelte.ts | 6 +- .../filters/tag-faceted-filter.svelte | 5 +- .../filters/tag-faceted-filter.svelte.test.ts | 30 ++++ .../tag-faceted-filter.test-harness.svelte | 10 ++ .../events/organization-query.svelte.test.ts | 73 +++++++++ .../src/lib/features/projects/api.svelte.ts | 4 +- .../websockets/web-socket-client.svelte.ts | 10 +- .../websockets/web-socket-client.test.ts | 10 ++ .../sidebar-organization-switcher.svelte | 46 +++--- .../(components)/layouts/sidebar-user.svelte | 16 +- .../ClientApp/src/routes/(app)/+layout.svelte | 16 +- .../(app)/account/notifications/+page.svelte | 11 +- .../[organizationId]/manage/+page.svelte | 14 +- .../routes/(app)/project/list/+page.svelte | 15 +- .../src/routes/(app)/stream/+page.svelte | 12 +- .../Hubs/MessageBusBrokerMiddleware.cs | 15 +- .../Exceptionless.Tests/Hubs/TestWebSocket.cs | 3 +- .../Hubs/WebSocketTests.cs | 45 ++++++ tests/http/websockets.http | 27 ++++ 23 files changed, 706 insertions(+), 68 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/e2e/fixtures/organization-test.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/organization-avatar.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/organization-context.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/project-organization-scoping.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.test-harness.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/organization-query.svelte.test.ts create mode 100644 tests/http/websockets.http 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..da96476241 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/organization-test.ts @@ -0,0 +1,66 @@ +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.signup('Impersonated organization owner', email, E2E_TEST_PASSWORD); + 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/tests/organization-avatar.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-avatar.e2e.ts new file mode 100644 index 0000000000..53d21fc90c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-avatar.e2e.ts @@ -0,0 +1,79 @@ +import { expect, test } from '../fixtures/organization-test'; + +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) => { + if (iconState === 'broken') { + await page.route(`**/api/v2/organizations/${impersonatedOrganization.organizationId}`, async (route) => { + const response = await route.fetch(); + const organization = await response.json(); + await route.fulfill({ json: { ...organization, icon_url: '/next/e2e-unavailable-organization-icon.png' }, response }); + }); + 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(); + await page.getByLabel('Upload organization icon').setInputFiles('static/favicon.png'); + + 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/project-organization-scoping.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-organization-scoping.e2e.ts new file mode 100644 index 0000000000..792fa07d38 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-organization-scoping.e2e.ts @@ -0,0 +1,115 @@ +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.signup('Impersonated organization owner', email, E2E_TEST_PASSWORD); + 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 @@