Skip to content
Merged
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
34 changes: 34 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,37 @@ test('user can recover from a failed login, restore the session, and log out', a
await authenticationContext.close();
}
});

test('login restores the full notification settings link and selected project', async ({ browser, e2eApi, e2eScenario, e2eSecondaryProject }) => {
const context = await browser.newContext({ baseURL: e2eApi.environment.appUrl, ignoreHTTPSErrors: true });
const page = await context.newPage();
const destination = `/next/account/notifications?project=${e2eSecondaryProject.projectId}&from=email%2Bnotification%26settings#project-notifications`;

try {
await test.step('preserve the complete destination when authentication is required', async () => {
await page.goto(destination);
await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible();
await expect.poll(() => new URL(page.url()).searchParams.get('redirect')).toBe(destination);
});

await test.step('return to the requested project after login', async () => {
await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email);
await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD);
await page.getByRole('button', { exact: true, name: 'Login' }).click();

await expect(page).toHaveURL(new URL(destination, e2eApi.environment.appUrl).href);
await expect(page.getByRole('heading', { exact: true, name: 'Project Notifications' })).toBeVisible();
await expect(page.getByRole('button', { exact: true, name: e2eSecondaryProject.projectName })).toBeVisible();
});

await test.step('preserve the same destination after the session expires', async () => {
await page.evaluate(() => localStorage.setItem('satellizer_token', 'expired-navigation-test-token'));
await page.reload();

await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible();
await expect.poll(() => new URL(page.url()).searchParams.get('redirect')).toBe(destination);
});
} finally {
await context.close();
}
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const paths = vi.hoisted(() => ({ base: '/next' }));

vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path}` }));
vi.mock('$app/state', () => ({ page: { url: new URL('https://exceptionless.local/') } }));

import type { AssistantToolActivity } from './models';

import { addAssistantResourceLinks, normalizeAssistantUrl } from './assistant-links';

beforeEach(() => {
paths.base = '/next';
});

function toolResult(items: unknown[]): AssistantToolActivity {
return {
arguments: '{}',
Expand All @@ -30,9 +39,40 @@ describe('normalizeAssistantUrl', () => {
const source = 'https://example.com/next/assets/chart.png';
expect(normalizeAssistantUrl(source, 'src')).toBe(source);
});

it('only rewrites same-origin absolute links when the app is hosted at root', () => {
paths.base = '';

expect(normalizeAssistantUrl('https://exceptionless.local/stack/stack-id?mode=summary#event', 'href')).toBe('/stack/stack-id?mode=summary#event');
expect(normalizeAssistantUrl('https://docs.exceptionless.com/product/errors', 'href')).toBe('https://docs.exceptionless.com/product/errors');
expect(normalizeAssistantUrl('https://example.com/stack/stack-id', 'href')).toBe('https://example.com/stack/stack-id');
expect(normalizeAssistantUrl('/stack/stack-id', 'href')).toBe('/stack/stack-id');
});

it('does not confuse a similar prefix with the app base', () => {
expect(normalizeAssistantUrl('https://example.com/nextdoor/stack/stack-id', 'href')).toBe('https://example.com/nextdoor/stack/stack-id');
});
});

describe('addAssistantResourceLinks', () => {
it('links root-hosted resources while preserving existing URLs and markdown', () => {
paths.base = '';
const content = 'See /project/API and https://example.test/API, [API](/project/existing), and `API` before opening API.';

expect(addAssistantResourceLinks(content, [toolResult([{ name: 'API', webUrl: '/project/api?tab=settings#details' }])])).toBe(
'See /project/API and https://example.test/API, [API](/project/existing), and `API` before opening [API](/project/api?tab=settings#details).'
);
});

it.each(['https://example.com/stack/1', '//example.com/stack/1', '/\\example.com/stack/1', '/\t/example.com/stack/1'])(
'rejects external resource URL %s at root',
(webUrl) => {
paths.base = '';

expect(addAssistantResourceLinks('Investigate this title.', [toolResult([{ title: 'this title', webUrl }])])).toBe('Investigate this title.');
}
);

it('links matching stack titles in tables and prose using tool-result web URLs', () => {
const content = `| Type | Title |
| --- | --- |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';

import type { AssistantToolActivity } from './models';

interface AssistantResourceLink {
Expand Down Expand Up @@ -32,7 +35,8 @@ export function normalizeAssistantUrl(url: string, key: string): string {

try {
const parsedUrl = new URL(url);
if (parsedUrl.pathname === '/next' || parsedUrl.pathname.startsWith('/next/')) {
// At the root, the pathname alone cannot distinguish app links from external links.
if (isAssistantPath(parsedUrl.pathname) && (resolve('/') !== '/' || parsedUrl.origin === page.url.origin)) {
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
}
} catch {
Expand All @@ -49,7 +53,7 @@ function collectAssistantResourceLink(value: unknown, urlsByLabel: Map<string, S

const label = readNonEmptyString(value, 'title') ?? readNonEmptyString(value, 'name');
const url = readNonEmptyString(value, 'webUrl');
if (label && url && (url === '/next' || url.startsWith('/next/'))) {
if (label && url && isAssistantPath(url)) {
const urls = urlsByLabel.get(label) ?? new Set<string>();
urls.add(url);
urlsByLabel.set(label, urls);
Expand Down Expand Up @@ -226,6 +230,11 @@ function getAssistantResourceLinks(tools: AssistantToolActivity[]): AssistantRes
.sort((left, right) => right.label.length - left.label.length);
}

function isAssistantPath(value: string): boolean {
const root = resolve('/');
return !value.startsWith('//') && !/[\\\s]/.test(value) && (value === root.slice(0, -1) || value.startsWith(root));
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
Expand All @@ -237,8 +246,10 @@ function readNonEmptyString(record: Record<string, unknown>, key: string): strin

function replaceOutsideProtectedMarkdown(content: string, replace: (text: string) => string): string {
const protectedMarkdown =
/(^(?:(?: {0,3}>[\t ]?)*(?: {4}|\t)[^\r\n]*(?:\r?\n|$))+|!?\[[^\]\n]*\]\s*\[[^\]\n]*\]|!?\[[^\]\n]*\]|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}|https?:\/\/[^\s<]+|\/next(?:\/[^\s<]*)?)/gm;
const protectedRanges = [...content.matchAll(protectedMarkdown)].map((match) => ({
/(^(?:(?: {0,3}>[\t ]?)*(?: {4}|\t)[^\r\n]*(?:\r?\n|$))+|!?\[[^\]\n]*\]\s*\[[^\]\n]*\]|!?\[[^\]\n]*\]|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}|https?:\/\/[^\s<]+)/gm;
const root = resolve('/');
const appPathPattern = root === '/' ? /\/[^\s<]*/g : new RegExp(`${escapeRegularExpression(root.slice(0, -1))}(?:/[^\\s<]*)?`, 'g');
const protectedRanges = [...content.matchAll(protectedMarkdown), ...content.matchAll(appPathPattern)].map((match) => ({
end: match.index + match[0].length,
start: match.index
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ export async function googleLogin(redirectUrl?: string, inviteToken?: null | str

export async function gotoLogin() {
const url = page.url;
const isAuthPath = url.pathname.startsWith('/next/login');
const redirect = url.pathname === resolve('/') || isAuthPath ? resolve('/(auth)/login') : `${resolve('/(auth)/login')}?redirect=${url.pathname}`;
const loginPath = resolve('/(auth)/login');
const isAuthPath = url.pathname === loginPath || url.pathname === `${loginPath}/`;
const returnUrl = `${url.pathname}${url.search}${url.hash}`;
const redirect = url.pathname === resolve('/') || isAuthPath ? loginPath : `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`;
await goto(redirect, {
replaceState: true
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { goto, page, paths } = vi.hoisted(() => ({
goto: vi.fn(),
page: { url: new URL('https://localhost/next/') },
paths: { base: '/next' }
}));

vi.mock('$app/navigation', () => ({ goto }));
vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path.replace('/(auth)', '')}` }));
vi.mock('$app/state', () => ({ page }));
vi.mock('$env/dynamic/public', () => ({ env: {} }));
vi.mock('./api.svelte', () => ({}));
vi.mock('./state.svelte', () => ({ accessToken: { current: null } }));
vi.mock('./validators', () => ({ validateEmailAvailability: vi.fn() }));

import { gotoLogin } from './index.svelte';

describe('gotoLogin', () => {
beforeEach(() => {
goto.mockReset();
paths.base = '/next';
});

it.each([
'/next/account/verify?token=verification%2Btoken%26value',
'/next/account/notifications?project=project-id&from=email#project-notifications',
'/next/organization/organization-id/billing?changePlan=true#billing',
'/next/event?filter=message%3A%22A%26B%22&tag=one&tag=two#details',
'/next/stack/stack-id'
])('preserves the complete return destination %s', async (destination) => {
page.url = new URL(destination, 'https://localhost');

await gotoLogin();

expect(goto).toHaveBeenCalledExactlyOnceWith(`/next/login?redirect=${encodeURIComponent(destination)}`, { replaceState: true });
const loginUrl = new URL(goto.mock.calls[0]![0], page.url.origin);
expect(loginUrl.searchParams.get('redirect')).toBe(destination);
expect([...loginUrl.searchParams.keys()]).toEqual(['redirect']);
expect(loginUrl.hash).toBe('');
});

it.each(['/next/', '/next/login', '/next/login/'])('does not nest a login redirect from %s', async (path) => {
page.url = new URL(path, 'https://localhost');

await gotoLogin();

expect(goto).toHaveBeenCalledExactlyOnceWith('/next/login', { replaceState: true });
});

it('uses the configured login route when the app is hosted at root', async () => {
paths.base = '';
page.url = new URL('https://localhost/login');

await gotoLogin();

expect(goto).toHaveBeenCalledExactlyOnceWith('/login', { replaceState: true });
});
});
32 changes: 32 additions & 0 deletions src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const paths = vi.hoisted(() => ({ base: '/next' }));

vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path}` }));

import { normalizePath } from './route';

describe('normalizePath', () => {
beforeEach(() => {
paths.base = '/next';
});

it.each(['/next', ''])('normalizes telemetry under the app base %j', (base) => {
paths.base = base;

expect(normalizePath(`${base}/stack/507f1f77bcf86cd799439011/`)).toBe('/stack/:id');
expect(normalizePath(`${base}/event/123/`)).toBe('/event/:id');
expect(normalizePath(`${base}/event/550e8400-e29b-41d4-a716-446655440000`)).toBe('/event/:id');
expect(normalizePath(`${base}/`)).toBe('/');
});

it('does not strip a partial base segment', () => {
expect(normalizePath('/nextdoor/event/123')).toBe('/nextdoor/event/:id');
expect(normalizePath('/next')).toBe('/');
});

it('preserves support for an explicit normalization base', () => {
expect(normalizePath('/custom/event/123', '/custom')).toBe('/event/:id');
expect(normalizePath('/next/event/123', '')).toBe('/next/event/:id');
});
});
6 changes: 4 additions & 2 deletions src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { resolve } from '$app/paths';

const OBJECTID_SEGMENT_REGEX = /^[0-9a-f]{24}$/i;
const UUID_SEGMENT_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const NUMERIC_SEGMENT_REGEX = /^\d+$/;

export function normalizePath(path: string, basePath = '/next'): string {
export function normalizePath(path: string, basePath = resolve('/').replace(/\/$/, '')): string {
let normalized = path;

if (basePath && normalized.startsWith(basePath)) {
if (basePath && (normalized === basePath || normalized.startsWith(`${basePath}/`))) {
normalized = normalized.slice(basePath.length);
}

Expand Down
23 changes: 18 additions & 5 deletions src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -167,21 +167,34 @@
}

const url = new URL(value, page.url.origin);
if (url.origin !== page.url.origin || url.pathname === assistantPageHref || !url.pathname.startsWith('/next/')) {
if (url.origin !== page.url.origin || url.pathname === assistantPageHref || !url.pathname.startsWith(resolve('/'))) {
return undefined;
}

return `${url.pathname}${url.search}${url.hash}`;
}

function getAssistantPath(context: AssistantResourceContext | undefined, fallback: string): string {
if (context?.eventId && context.stackId) {
return resolve('/(app)/stack/[stackId=objectid]/event/[eventId=objectid]', {
eventId: encodeURIComponent(context.eventId),
stackId: encodeURIComponent(context.stackId)
});
}

if (context?.eventId) {
return context.stackId
? `/next/stack/${encodeURIComponent(context.stackId)}/event/${encodeURIComponent(context.eventId)}`
: `/next/event/${encodeURIComponent(context.eventId)}`;
return resolve('/(app)/event/[eventId=objectid]', {
eventId: encodeURIComponent(context.eventId)
});
}

if (context?.stackId) {
return resolve('/(app)/stack/[stackId=objectid]', {
stackId: encodeURIComponent(context.stackId)
});
}

return context?.stackId ? `/next/stack/${encodeURIComponent(context.stackId)}` : fallback;
return fallback;
}

async function openOrganizationSwitcher(): Promise<void> {
Expand Down
Loading