mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
* 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
314 lines
9.6 KiB
TypeScript
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;
|
|
};
|
|
}
|