|
| 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