Files
teleport/e2e/helpers/test.ts
T
Ryan Clark 286ce6d8d8 Change the way e2e logs in for tests, enable unauthenticated tests (#69637)
* Change the way e2e logs in for tests, enable unauthenticated tests

* Change the SSH clipboard test to use the browser selector instead of skipping

* Remove workaround in darktheme test

* Share login state for connect tests

* Keep the DisposableTempDir instead of the path
2026-08-13 15:32:59 +00:00

314 lines
9.6 KiB
TypeScript

/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { readFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { authStateFor } from './authState';
import { canonicalUserKey } from './canonicalKey';
import { testClientIp } from './clientIp';
import { test as base } from './fixtures';
import { PlayerPage } from './pages/Player';
import { RecordingsPage } from './pages/Recordings';
import { RolesPage } from './pages/Roles';
import { SideNavPage } from './pages/SideNav';
import { TrustedClustersPage } from './pages/TrustedClusters';
import { UnifiedResourcesPage } from './pages/UnifiedResources';
import { mockWebAuthn } from './webauthn';
export const CLUSTER_NAME = 'teleport-e2e';
export type UserRole = string | { file: string };
export interface UserTraits {
logins?: string[];
windows_logins?: string[];
kubernetes_groups?: string[];
kubernetes_users?: string[];
db_names?: string[];
db_users?: string[];
db_roles?: string[];
aws_role_arns?: string[];
azure_identities?: string[];
gcp_service_accounts?: string[];
host_user_uid?: string[];
host_user_gid?: string[];
[key: string]: string[] | undefined;
}
export interface UserDefinition {
roles: UserRole[];
traits?: UserTraits;
recordings?: string[];
loginAs?: boolean;
}
/**
* TeleportOption lets a test declare a custom Teleport cluster config to use.
**/
export interface TeleportOption {
config: Record<string, unknown>;
env?: Record<string, string>;
}
const e2eDir =
process.env.E2E_DIR ?? join(dirname(fileURLToPath(import.meta.url)), '..');
const authDir = join(e2eDir, '.auth');
const tryLoadUserMapping =
cachedJSONLoader<Record<string, string>>('user-mapping.json');
const tryLoadRecordingMapping = cachedJSONLoader<
Record<string, Record<string, string>>
>('recording-mapping.json');
const defaultUser: UserDefinition = { roles: ['access', 'editor'] };
type Browser = 'chromium' | 'firefox' | 'webkit';
interface E2EFixtures {
recordings: string[];
user: UserDefinition;
users: UserDefinition[];
teleport: TeleportOption;
browsers: Browser[];
username: string;
loginAs: (index: number) => Promise<LoginAsResult>;
recordingIds: Record<string, string>;
unifiedResourcesPage: UnifiedResourcesPage;
playerPage: PlayerPage;
recordingsPage: RecordingsPage;
rolesPage: RolesPage;
sideNavPage: SideNavPage;
trustedClustersPage: TrustedClustersPage;
}
export interface LoginAsResult {
name: string;
recordingIds: Record<string, string>;
}
export const test = base.extend<E2EFixtures>({
extraHTTPHeaders: async ({ extraHTTPHeaders }, use, testInfo) => {
// Only the runner's own cluster is configured to trust X-Forwarded-For. Tagging a cluster we don't own
// (--teleport-url) would corrupt its client IPs, and E2E_USERS_FILE is set only when the runner
// bootstrapped this instance.
if (!process.env.E2E_USERS_FILE) {
await use(extraHTTPHeaders);
return;
}
await use({
...extraHTTPHeaders,
'X-Forwarded-For': testClientIp(testInfo.testId),
});
},
recordings: [[], { option: true }],
user: [undefined as unknown as UserDefinition, { option: true }],
users: [[], { option: true }],
teleport: [undefined as unknown as TeleportOption, { option: true }],
browsers: [[], { option: true }],
username: async ({ user, users }, use, testInfo) => {
const mapping = tryLoadUserMapping() ?? {};
const picked = pickLoginDefinition(user, users);
if (picked) {
const source = relSpecPath(testInfo);
const name =
mapping[
canonicalUserKey(picked.def, { index: picked.index, source })
] ?? mapping[canonicalUserKey(picked.def, { index: picked.index })];
await use(name ?? '');
return;
}
if (wantsAuthenticatedUser(testInfo.project.name)) {
await use(
mapping[canonicalUserKey(defaultUser, { isDefault: true })] ?? ''
);
return;
}
await use('');
},
loginAs: async ({ page, users }, use, testInfo) => {
const mapping = tryLoadUserMapping();
if (!mapping) {
throw new Error(
`failed to read user mapping at ${join(authDir, 'user-mapping.json')} — was the runner started?`
);
}
await use(async (index: number): Promise<LoginAsResult> => {
const definition = users[index];
if (!definition) {
throw new Error(
`users[${index}] is undefined — declare ${index + 1} users in test.use()`
);
}
const source = relSpecPath(testInfo);
const name =
mapping[canonicalUserKey(definition, { index, source })] ??
mapping[canonicalUserKey(definition, { index })];
if (!name) {
throw new Error(
`no generated username for users[${index}]: ${JSON.stringify(definition)}`
);
}
const state = await authStateFor(name);
const ctx = page.context();
await ctx.clearCookies();
await ctx.addCookies(state.cookies);
// The page's WebAuthn mock is bound to the user the page fixture
// started with; rebind it so subsequent MFA prompts answer with the
// switched user's credential.
await mockWebAuthn(page, name);
// localStorage is origin-scoped; inject per origin after navigation.
await page.goto('/');
for (const { origin, localStorage: items } of state.origins) {
await page.evaluate(
({ expected, entries }) => {
if (location.origin !== expected) return;
window.localStorage.clear();
for (const { name, value } of entries) {
window.localStorage.setItem(name, value);
}
},
{ expected: origin, entries: items }
);
}
await page.reload();
const recordingMapping = tryLoadRecordingMapping() ?? {};
return { name, recordingIds: recordingMapping[name] ?? {} };
});
},
recordingIds: async ({ username }, use) => {
if (!username) {
throw new Error(
'recordingIds requested without a logged-in user — declare user/users (or recordings) in test.use()'
);
}
const mapping = tryLoadRecordingMapping() ?? {};
await use(mapping[username] ?? {});
},
page: async ({ page, username }, use) => {
if (username) {
await mockWebAuthn(page, username);
}
await use(page);
},
storageState: async ({ username }, use, testInfo) => {
// Connect tests drive login through the Electron app, and unauthenticated
// tests intentionally start without a session, so neither needs a session
// minted even when the test declares a user.
if (
!username ||
testInfo.project.name === 'connect' ||
testInfo.project.name.endsWith(':unauthenticated')
) {
await use(undefined as unknown as string);
return;
}
await use(await authStateFor(username));
},
unifiedResourcesPage: async ({ page }, use) => {
await use(new UnifiedResourcesPage(page));
},
playerPage: async ({ page }, use) => {
await use(new PlayerPage(page));
},
recordingsPage: async ({ page }, use) => {
await use(new RecordingsPage(page));
},
rolesPage: async ({ page }, use) => {
await use(new RolesPage(page));
},
sideNavPage: async ({ page }, use) => {
await use(new SideNavPage(page));
},
trustedClustersPage: async ({ page }, use) => {
await use(new TrustedClustersPage(page));
},
});
export { expect } from '@playwright/test';
export type { Locator, Page } from '@playwright/test';
function pickLoginDefinition(
user: UserDefinition | undefined,
users: UserDefinition[]
): { def: UserDefinition; index?: number } | undefined {
if (user) {
return { def: user };
}
if (users.length === 0) {
return undefined;
}
const loginIdx = users.findIndex(u => u.loginAs);
const i = loginIdx >= 0 ? loginIdx : 0;
return { def: users[i], index: i };
}
// wantsAuthenticatedUser returns true for projects whose tests need a logged-in
// user even when test.use() doesn't declare one — `:authenticated` web tests
// and Connect tests both fall through to the default access/editor user.
function wantsAuthenticatedUser(projectName: string): boolean {
return projectName.endsWith(':authenticated') || projectName === 'connect';
}
// relSpecPath returns the spec file path relative to e2e/, matching the
// `source` field the Go scanner emits for inline declarations.
function relSpecPath(testInfo: { file: string }) {
return relative(e2eDir, testInfo.file);
}
// Mapping files are written once at runner startup and don't change during a
// test run, so each worker can cache them after first read.
function cachedJSONLoader<T>(filename: string): () => T | undefined {
let cached: T | undefined;
let loaded = false;
return () => {
if (!loaded) {
loaded = true;
try {
cached = JSON.parse(readFileSync(join(authDir, filename), 'utf-8'));
} catch {
cached = undefined;
}
}
return cached;
};
}