Skip to content

Commit 189bc42

Browse files
authored
test: E2E tests for tenant branding (#1973)
* test: add E2E tests for tenant branding Covers login page heading, browser title, and header for both bare domain and tenant subdomain scenarios. - Bare domain: heading and title show "Meridian Operations Console" - Tenant subdomain (mocked /api/tenant-info): heading shows display name, title shows "{Name} - Operations Console" - Header shows JWT tenantDisplayName (x-tenant-display-name claim) when present, falls back to "Meridian" when absent * test: fix header fallback test to use platform admin fixture The authenticatedPage fixture has tenantId='dev-tenant' which causes the header to show "Dev Tenant" via formatSlugAsDisplayName. The "Meridian" fallback applies to platform admins with no tenant selected. * test: use authenticated tenant fixture for header fallback test Replace platform admin fixture with authenticatedPage fixture. The platform admin TenantSelector issues an API call on mount causing timing uncertainty. Using a tenant user with tenantId='dev-tenant' and no x-tenant-display-name reliably tests the slug fallback path: formatSlugAsDisplayName('dev-tenant') = "Dev Tenant". * test: use UTF-8 safe base64url encoding for JWT segments btoa() only handles Latin-1 input and throws InvalidCharacterError for non-ASCII tenant display names. Encode via TextEncoder -> btoa -> base64url so tokens work for any Unicode display name. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent f6a4561 commit 189bc42

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { test, expect, type Page } from '@playwright/test'
2+
import { test as authTest, expect as authExpect } from './fixtures'
3+
4+
/**
5+
* UTF-8 safe base64url encoder for JWT segments.
6+
* btoa() only accepts Latin-1, so non-ASCII display names (e.g., "東京電力") would
7+
* throw InvalidCharacterError. TextEncoder handles the full Unicode range.
8+
*/
9+
function encodeJwtSegment(value: Record<string, unknown>): string {
10+
const bytes = new TextEncoder().encode(JSON.stringify(value))
11+
let binary = ''
12+
for (const byte of bytes) binary += String.fromCharCode(byte)
13+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
14+
}
15+
16+
/**
17+
* Build a dev-mode JWT with an explicit tenant display name claim.
18+
* The auth context reads `x-tenant-display-name` to populate `claims.tenantDisplayName`.
19+
*/
20+
function buildTokenWithDisplayName(displayName: string): string {
21+
const header = encodeJwtSegment({ alg: 'none', typ: 'JWT' })
22+
const payload = encodeJwtSegment({
23+
userId: 'e2e-user',
24+
tenantId: 'dev-tenant',
25+
roles: ['tenant-user'],
26+
scopes: ['read', 'write'],
27+
'x-tenant-display-name': displayName,
28+
exp: Math.floor(Date.now() / 1000) + 86_400,
29+
iss: 'meridian-dev',
30+
aud: 'meridian-console',
31+
sub: 'e2e-user',
32+
})
33+
return `${header}.${payload}.e2e-signature`
34+
}
35+
36+
/**
37+
* Inject a dev token via __DEV_LOGIN__ and navigate to / so the authenticated
38+
* app shell renders with the given token's claims.
39+
*/
40+
async function injectTokenAndNavigate(page: Page, token: string) {
41+
await page.goto('/')
42+
await page.waitForFunction(
43+
() => typeof (window as Record<string, unknown>).__DEV_LOGIN__ === 'function',
44+
)
45+
await page.evaluate((t) => {
46+
;(window as Record<string, unknown>).__DEV_LOGIN__(t)
47+
}, token)
48+
await page.evaluate(() => {
49+
window.history.pushState({}, '', '/')
50+
window.dispatchEvent(new PopStateEvent('popstate'))
51+
})
52+
await page.waitForSelector('main', { timeout: 10_000 })
53+
}
54+
55+
/**
56+
* Tenant branding E2E tests.
57+
*
58+
* The test environment runs on localhost, which has no tenant subdomain.
59+
* Tenant subdomain scenarios are simulated by mocking the /api/tenant-info
60+
* endpoint via page.route().
61+
*
62+
* Scenarios covered:
63+
* - Bare domain login page heading and browser title
64+
* - Tenant subdomain login page heading and browser title (mocked API)
65+
* - Header shows tenant display name from JWT after login
66+
* - Header shows "Meridian" when no display name is present
67+
*/
68+
69+
test.describe('Tenant branding - bare domain login page', () => {
70+
test('shows "Meridian Operations Console" heading', async ({ page }) => {
71+
await page.goto('/login')
72+
await expect(page.getByRole('heading', { name: 'Meridian Operations Console' })).toBeVisible()
73+
})
74+
75+
test('browser title is "Meridian Operations Console"', async ({ page }) => {
76+
await page.goto('/login')
77+
// Wait for the heading to confirm the page has settled
78+
await page.getByRole('heading', { name: 'Meridian Operations Console' }).waitFor()
79+
await expect(page).toHaveTitle('Meridian Operations Console')
80+
})
81+
})
82+
83+
test.describe('Tenant branding - tenant subdomain login page', () => {
84+
test('shows tenant display name in login heading', async ({ page }) => {
85+
await page.route('/api/tenant-info', (route) =>
86+
route.fulfill({
87+
status: 200,
88+
contentType: 'application/json',
89+
body: JSON.stringify({ slug: 'acme', displayName: 'Acme Energy' }),
90+
}),
91+
)
92+
await page.goto('/login')
93+
await expect(
94+
page.getByRole('heading', { name: 'Acme Energy Operations Console' }),
95+
).toBeVisible()
96+
})
97+
98+
test('browser title shows "{Tenant Name} - Operations Console"', async ({ page }) => {
99+
await page.route('/api/tenant-info', (route) =>
100+
route.fulfill({
101+
status: 200,
102+
contentType: 'application/json',
103+
body: JSON.stringify({ slug: 'acme', displayName: 'Acme Energy' }),
104+
}),
105+
)
106+
await page.goto('/login')
107+
await page.getByRole('heading', { name: 'Acme Energy Operations Console' }).waitFor()
108+
await expect(page).toHaveTitle('Acme Energy - Operations Console')
109+
})
110+
})
111+
112+
test.describe('Tenant branding - header after login', () => {
113+
test('header shows tenant display name from JWT', async ({ page }) => {
114+
const token = buildTokenWithDisplayName('Volterra Energy')
115+
await injectTokenAndNavigate(page, token)
116+
await expect(page.locator('header').getByText('Volterra Energy')).toBeVisible()
117+
})
118+
119+
authTest('header shows formatted slug when JWT has no display name', async ({
120+
authenticatedPage: page,
121+
}) => {
122+
// Tenant user with tenantId='dev-tenant' but no x-tenant-display-name in the JWT.
123+
// Falls back to formatSlugAsDisplayName('dev-tenant') = "Dev Tenant".
124+
await authExpect(page.locator('header').getByText('Dev Tenant')).toBeVisible()
125+
})
126+
})

0 commit comments

Comments
 (0)