|
| 1 | +import type { Page } from '@playwright/test'; |
| 2 | + |
| 3 | +import { expect, test } from '../fixtures/e2e-test'; |
| 4 | +import { seedRepresentativeEvent } from '../support/event-data'; |
| 5 | + |
| 6 | +test('organization deep links select the destination organization', async ({ e2eScenario, e2eSecondaryOrganization, page }) => { |
| 7 | + await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/manage?from=link#settings`); |
| 8 | + |
| 9 | + await expect(page).toHaveURL(new RegExp(`/organization/${e2eSecondaryOrganization.organizationId}/manage\\?from=link#settings$`)); |
| 10 | + await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.organizationName} Settings` })).toBeVisible(); |
| 11 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 12 | + expect(e2eScenario.organizationId).not.toBe(e2eSecondaryOrganization.organizationId); |
| 13 | +}); |
| 14 | + |
| 15 | +for (const resource of ['project', 'stack', 'event', 'stack event', 'project stack', 'mismatched project stack'] as const) { |
| 16 | + test(`${resource} deep links select the destination organization`, async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { |
| 17 | + const event = resource === 'project' ? undefined : await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eSecondaryOrganization); |
| 18 | + let href: string; |
| 19 | + switch (resource) { |
| 20 | + case 'event': |
| 21 | + href = `/next/event/${event!.id}`; |
| 22 | + break; |
| 23 | + case 'mismatched project stack': |
| 24 | + href = `/next/project/${e2eScenario.projectId}/stacks/${event!.stack_id}`; |
| 25 | + break; |
| 26 | + case 'project': |
| 27 | + href = `/next/project/${e2eSecondaryOrganization.projectId}/settings`; |
| 28 | + break; |
| 29 | + case 'project stack': |
| 30 | + href = `/next/project/${e2eSecondaryOrganization.projectId}/stacks/${event!.stack_id}`; |
| 31 | + break; |
| 32 | + case 'stack': |
| 33 | + href = `/next/stack/${event!.stack_id}`; |
| 34 | + break; |
| 35 | + case 'stack event': |
| 36 | + href = `/next/stack/${event!.stack_id}/event/${event!.id}`; |
| 37 | + break; |
| 38 | + } |
| 39 | + |
| 40 | + await page.goto(href); |
| 41 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 42 | + if (event) { |
| 43 | + await expect(page.getByText(e2eSecondaryOrganization.message, { exact: true }).filter({ visible: true }).first()).toBeVisible(); |
| 44 | + await expect(page).toHaveURL(resource === 'project stack' ? href : new RegExp(`/stack/${event.stack_id}/event/${event.id}$`)); |
| 45 | + } else { |
| 46 | + await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.projectName} Settings` })).toBeVisible(); |
| 47 | + } |
| 48 | + |
| 49 | + await page.reload(); |
| 50 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 51 | + await page.getByRole('button').filter({ hasText: e2eSecondaryOrganization.organizationName }).filter({ visible: true }).first().click(); |
| 52 | + await page.getByRole('menuitem').filter({ hasText: e2eScenario.organizationName }).click(); |
| 53 | + await expectOrganization(page, e2eScenario.organizationId); |
| 54 | + await expect(page).toHaveURL(/\/next\/stack\/all$/); |
| 55 | + }); |
| 56 | +} |
| 57 | + |
| 58 | +async function expectOrganization(page: Page, organizationId: string): Promise<void> { |
| 59 | + await expect(async () => { |
| 60 | + const activeOrganizationId = await page.evaluate(() => JSON.parse(localStorage.getItem('organization') ?? 'null')); |
| 61 | + expect(activeOrganizationId).toBe(organizationId); |
| 62 | + }).toPass({ timeout: 10_000 }); |
| 63 | +} |
| 64 | + |
| 65 | +test('Exie usage organization links switch context and support browser history', async ({ e2eScenario, e2eSecondaryOrganization, page }) => { |
| 66 | + const organizations = [e2eScenario, e2eSecondaryOrganization]; |
| 67 | + await page.route('**/api/v2/admin/assistant-usage?*', async (route) => { |
| 68 | + await route.fulfill({ |
| 69 | + json: { |
| 70 | + active_organizations: 2, |
| 71 | + completion_tokens: 0, |
| 72 | + cost_usd: 0, |
| 73 | + month: '2026-09-01', |
| 74 | + organizations: organizations.map((item) => ({ |
| 75 | + blocked_by_concurrency: 0, |
| 76 | + blocked_by_cost_limit: 0, |
| 77 | + blocked_by_rate_limit: 0, |
| 78 | + blocked_by_token_limit: 0, |
| 79 | + cancelled: 0, |
| 80 | + completed: 0, |
| 81 | + completion_tokens: 0, |
| 82 | + cost_usd: 0, |
| 83 | + failed: 0, |
| 84 | + last_used_utc: new Date().toISOString(), |
| 85 | + organization_id: item.organizationId, |
| 86 | + organization_name: item.organizationName, |
| 87 | + plan_id: 'FREE', |
| 88 | + prompt_tokens: 0, |
| 89 | + provider_requests: 0, |
| 90 | + tool_calls: 0, |
| 91 | + turns: 0 |
| 92 | + })), |
| 93 | + prompt_tokens: 0, |
| 94 | + turns: 0 |
| 95 | + } |
| 96 | + }); |
| 97 | + }); |
| 98 | + |
| 99 | + await page.goto('/next/system/exie'); |
| 100 | + await page.getByRole('link', { exact: true, name: e2eSecondaryOrganization.organizationName }).click(); |
| 101 | + await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.organizationName} Settings` })).toBeVisible(); |
| 102 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 103 | + await expect(page).toHaveURL(new RegExp(`/organization/${e2eSecondaryOrganization.organizationId}/manage$`)); |
| 104 | + |
| 105 | + await test.info().attach('organization-link-destination', { body: await page.screenshot(), contentType: 'image/png' }); |
| 106 | + await page.goto(`/next/organization/${e2eScenario.organizationId}/manage`); |
| 107 | + await expectOrganization(page, e2eScenario.organizationId); |
| 108 | + await expect(page.getByRole('heading', { exact: true, name: `${e2eScenario.organizationName} Settings` })).toBeVisible(); |
| 109 | + await page.goBack(); |
| 110 | + await expect(page).toHaveURL(new RegExp(`/organization/${e2eSecondaryOrganization.organizationId}/manage$`)); |
| 111 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 112 | + await page.goForward(); |
| 113 | + await expect(page).toHaveURL(new RegExp(`/organization/${e2eScenario.organizationId}/manage$`)); |
| 114 | + await expectOrganization(page, e2eScenario.organizationId); |
| 115 | +}); |
| 116 | + |
| 117 | +test('organization users and billing links request the destination organization', async ({ e2eScenario, e2eSecondaryOrganization, page }) => { |
| 118 | + const usersResponse = page.waitForResponse((response) => response.url().includes(`/organizations/${e2eSecondaryOrganization.organizationId}/users`)); |
| 119 | + await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/users`); |
| 120 | + expect((await usersResponse).ok()).toBe(true); |
| 121 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 122 | + await expect(page.getByRole('button', { name: 'Invite User' })).toBeVisible(); |
| 123 | + |
| 124 | + const billingResponse = page.waitForResponse((response) => response.url().includes(`/organizations/${e2eScenario.organizationId}/invoices`)); |
| 125 | + await page.goto(`/next/organization/${e2eScenario.organizationId}/billing`); |
| 126 | + expect([200, 404]).toContain((await billingResponse).status()); |
| 127 | + await expectOrganization(page, e2eScenario.organizationId); |
| 128 | + await expect(page).toHaveURL(new RegExp(`/organization/${e2eScenario.organizationId}/billing$`)); |
| 129 | +}); |
| 130 | + |
| 131 | +test('organization project links switch before redirecting to the project list', async ({ e2eSecondaryOrganization, page }) => { |
| 132 | + await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/projects`); |
| 133 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 134 | + await expect(page).toHaveURL(/\/next\/project\/list/); |
| 135 | + await expect(page.getByText(e2eSecondaryOrganization.projectName, { exact: true }).filter({ visible: true }).first()).toBeVisible(); |
| 136 | +}); |
| 137 | + |
| 138 | +test('stack links select the owner when no events remain', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { |
| 139 | + const event = await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, e2eSecondaryOrganization); |
| 140 | + await page.route(`**/api/v2/stacks/${event.stack_id}/events?*`, async (route) => { |
| 141 | + await route.fulfill({ json: [] }); |
| 142 | + }); |
| 143 | + await page.goto(`/next/stack/${event.stack_id}`); |
| 144 | + await expect(page.getByText('No events available for this stack.')).toBeVisible(); |
| 145 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 146 | + await expect(page).toHaveURL(new RegExp(`/stack/${event.stack_id}$`)); |
| 147 | +}); |
| 148 | + |
| 149 | +test('authorized invoice links select their organization', async ({ e2eSecondaryOrganization, page }) => { |
| 150 | + const invoiceId = 'navigation-invoice'; |
| 151 | + await page.route(`**/api/v2/organizations/invoice/${invoiceId}`, async (route) => { |
| 152 | + await route.fulfill({ |
| 153 | + json: { |
| 154 | + date: new Date().toISOString(), |
| 155 | + id: invoiceId, |
| 156 | + items: [], |
| 157 | + organization_id: e2eSecondaryOrganization.organizationId, |
| 158 | + organization_name: e2eSecondaryOrganization.organizationName, |
| 159 | + paid: true, |
| 160 | + status: 'paid', |
| 161 | + total: 0 |
| 162 | + } |
| 163 | + }); |
| 164 | + }); |
| 165 | + await page.goto(`/next/payment/${invoiceId}`); |
| 166 | + await expect(page.getByRole('cell', { exact: true, name: e2eSecondaryOrganization.organizationName })).toBeVisible(); |
| 167 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 168 | + await expect(page).toHaveURL(new RegExp(`/payment/${invoiceId}$`)); |
| 169 | +}); |
| 170 | + |
| 171 | +test.describe('member access', () => { |
| 172 | + test.use({ e2eUseGeneratedUser: true }); |
| 173 | + |
| 174 | + test('members can follow links to another organization they belong to', async ({ e2eScenario, e2eSecondaryOrganization, page }) => { |
| 175 | + await page.goto(`/next/organization/${e2eSecondaryOrganization.organizationId}/manage`); |
| 176 | + await expect(page.getByRole('heading', { exact: true, name: `${e2eSecondaryOrganization.organizationName} Settings` })).toBeVisible(); |
| 177 | + await expectOrganization(page, e2eSecondaryOrganization.organizationId); |
| 178 | + await page.goto(`/next/project/${e2eScenario.projectId}/settings`); |
| 179 | + await expect(page.getByRole('heading', { exact: true, name: `${e2eScenario.projectName} Settings` })).toBeVisible(); |
| 180 | + await expectOrganization(page, e2eScenario.organizationId); |
| 181 | + }); |
| 182 | + |
| 183 | + for (const status of [403, 404]) { |
| 184 | + test(`denied resource lookups (${status}) preserve the current organization`, async ({ e2eScenario, page }) => { |
| 185 | + const resourceId = '000000000000000000000001'; |
| 186 | + await page.route(new RegExp(`/api/v2/(organizations|projects|stacks|events)/${resourceId}(?:[/?]|$)`), async (route) => { |
| 187 | + await route.fulfill({ body: JSON.stringify({ status, title: 'Resource unavailable' }), contentType: 'application/problem+json', status }); |
| 188 | + }); |
| 189 | + for (const resource of ['organization', 'project', 'stack', 'event']) { |
| 190 | + const response = page.waitForResponse((item) => item.url().includes(`/api/v2/${resource}s/${resourceId}`)); |
| 191 | + const suffix = resource === 'organization' ? '/manage' : resource === 'project' ? '/settings' : ''; |
| 192 | + await page.goto(`/next/${resource}/${resourceId}${suffix}`); |
| 193 | + expect((await response).status()).toBe(status); |
| 194 | + await expect( |
| 195 | + page |
| 196 | + .getByText(/Resource unavailable|could not be found|Unable to load stack event details/) |
| 197 | + .filter({ visible: true }) |
| 198 | + .first() |
| 199 | + ).toBeVisible(); |
| 200 | + await expectOrganization(page, e2eScenario.organizationId); |
| 201 | + } |
| 202 | + }); |
| 203 | + } |
| 204 | +}); |
0 commit comments