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
This commit is contained in:
Ryan Clark
2026-08-13 15:32:59 +00:00
committed by GitHub
parent 133844070f
commit 286ce6d8d8
20 changed files with 347 additions and 208 deletions
+6
View File
@@ -39,6 +39,12 @@ kubernetes_service:
proxy_service:
enabled: "yes"
# The web API's rate limiter buckets by client IP, so without this the whole run shares one bucket.
trust_x_forwarded_for: true
# The tests claim IPv6 addresses, and the proxy signs a PROXY header pairing that source with the destination
# of the connection it arrived on. That destination is whatever `localhost` resolves to, which is ::1 on some
# machines and 127.0.0.1 in CI containers. Downgrading covers the IPv4 case; signing would reject it outright.
proxy_protocol_allow_downgrade: true
public_addr: localhost:{{ .ProxyPort }}
acme: {}
web_listen_addr: 0.0.0.0:{{ .ProxyPort }}
-66
View File
@@ -1,66 +0,0 @@
/**
* 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 { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const e2eDir = process.env.E2E_DIR ?? dirname(fileURLToPath(import.meta.url));
const authDir = join(e2eDir, '.auth');
async function globalSetup() {
const browser = process.env.E2E_BROWSER;
if (!browser) {
throw new Error('E2E_BROWSER must be set by the runner');
}
// No bootstrapped users — e.g. runs against an existing cluster via
// --teleport-url, or unauthenticated/connect-only runs. There's nothing to
// set up, and helpers/env throws on the missing E2E_USERS_FILE, so bail
// before importing it.
if (!process.env.E2E_USERS_FILE) {
return;
}
// Imported lazily so reading E2E_USERS_FILE only happens once we know there
// are credentials to generate auth state from.
const { startUrl, users } = await import('./helpers/env');
const { directLogin } = await import('./helpers/login');
mkdirSync(authDir, { recursive: true });
for (const [username, creds] of Object.entries(users)) {
const state = await directLogin(startUrl, username, creds.password);
writeFileSync(
join(authDir, `${browser}-${username}.json`),
JSON.stringify(state, null, 2)
);
}
}
export { globalSetup as default };
// When executed directly (e.g. `tsx global-setup.ts`) rather than imported as
// Playwright's configured globalSetup, run immediately. The runner's browse and
// codegen modes use this to generate auth state without a full test run.
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
await globalSetup();
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 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 type { StorageState } from './login';
// Sessions are minted on first use rather than up front for every declared user. Bunching them together drew
// the whole run's worth of tokens from the proxy's rate limiter inside a few hundred milliseconds, and the auth
// side's challenge limiter buckets every login together no matter which address the request claims.
//
// The promise is cached rather than the result, so concurrent callers share one login. Module scope survives
// across tests within a Playwright worker, which is what makes the cache worth having.
const states = new Map<string, Promise<StorageState>>();
// authStateFor returns a logged-in storage state for the user, reusing the one already minted in this process.
export function authStateFor(username: string) {
let state = states.get(username);
if (state) {
return state;
}
state = login(username);
states.set(username, state);
return state;
}
async function login(username: string) {
// Imported lazily because helpers/env throws on a missing E2E_USERS_FILE, and runs against an existing
// cluster never reach this path.
const [{ startUrl, users }, { directLogin }] = await Promise.all([
import('./env'),
import('./login'),
]);
const creds = users[username];
if (!creds) {
throw new Error(`no credentials found for user "${username}"`);
}
return directLogin(startUrl, username, creds.password, creds.clientIp);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 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/>.
*/
// The proxy's rate limiter buckets by client IP, so a whole run sharing one address lets setup logins and
// unauthenticated specs exhaust each other's budget. With trust_x_forwarded_for enabled on the test cluster,
// every browser context claims its own address instead.
//
// IPv6 is required, not cosmetic: the proxy signs a PROXY header pairing this source with the destination of
// the browser's connection, which is ::1 because the tests reach the proxy over localhost, and signing rejects
// a version mismatch. The runner hands bootstrapped users fd00:e2e:1:: (assignClientIP in
// runner/usercredentials.go), so per-test addresses sit under a different prefix.
const TEST_PREFIX = 'fd00:e2e:2';
// The hash fills the two trailing groups, giving 32 bits of address per test.
export function testClientIp(testId: string) {
const h = fnv1a(testId);
const hi = (h >>> 16).toString(16);
const lo = (h & 0xffff).toString(16);
return `${TEST_PREFIX}:${hi}::${lo}`;
}
// fnv1a generates a short string hash for a test ID.
function fnv1a(s: string) {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
+106 -16
View File
@@ -17,6 +17,7 @@
*/
import fs from 'node:fs/promises';
import type { DisposableTempDir } from 'node:fs/promises';
import module from 'node:module';
import os from 'node:os';
import path from 'node:path';
@@ -125,16 +126,104 @@ export interface App {
appConfigPath: string;
}
export const test = fixtureBase.extend<{
autoLogin: boolean;
/**
* Sets app config before launching the app.
*
* Use `withDefaultAppConfig` for normal config overrides.
*/
appConfig: AppConfigSetup;
app: App;
}>({
// Connect's login goes through webapi/mfa/login/{begin,finish}, which are rate limited per client IP. tshd
// makes those requests itself, so it can't carry the per-test X-Forwarded-For the browser tests use, and every
// Connect login shares one bucket. Logging in once per user and copying the resulting data dir keeps the count
// to one regardless of how many specs need a session.
const snapshots = new Map<string, Promise<DisposableTempDir>>();
function loggedInSnapshot(username: string, creds: UserCredentials) {
let snapshot = snapshots.get(username);
if (snapshot) {
return snapshot;
}
snapshot = createLoggedInSnapshot(username, creds);
snapshots.set(username, snapshot);
return snapshot;
}
async function createLoggedInSnapshot(
username: string,
creds: UserCredentials
) {
const dir = await fs.mkdtempDisposable(
path.join(os.tmpdir(), 'connect-e2e-snapshot-')
);
try {
await initializeDataDir(dir.path, withDefaultAppConfig({}));
// Scoped so the app closes, flushing the tsh profile and app state to disk, before anything copies the dir.
{
await using app = await launchApp(dir.path, creds);
await login(app.page, username, creds.password);
}
} catch (error) {
await dir.remove();
throw error;
}
return dir;
}
/**
* loggedInDataDir returns a disposable data dir already holding a logged-in session. Defaults to the same user
* as `launchApp` and `login` when none is given.
*/
export async function loggedInDataDir(
username?: string,
creds?: UserCredentials
) {
const name = username ?? defaultUsername();
const snapshot = await loggedInSnapshot(name, creds ?? lookupCreds(name));
const temp = await fs.mkdtempDisposable(
path.join(os.tmpdir(), 'connect-e2e-test-')
);
try {
await fs.cp(snapshot.path, temp.path, { recursive: true });
} catch (error) {
await temp.remove();
throw error;
}
return temp;
}
export const test = fixtureBase.extend<
{
autoLogin: boolean;
/**
* Sets app config before launching the app.
*
* Use `withDefaultAppConfig` for normal config overrides.
*/
appConfig: AppConfigSetup;
app: App;
},
{ snapshotCleanup: void }
>({
// Snapshots hold live certs and keys, so drop them when the worker exits rather than leaving them behind in
// the temp dir.
snapshotCleanup: [
// oxlint-disable-next-line no-empty-pattern
async ({}, use) => {
await use();
const dirs = await Promise.allSettled(snapshots.values());
snapshots.clear();
await Promise.all(
dirs.map(dir =>
dir.status === 'fulfilled' ? dir.value.remove() : undefined
)
);
},
{ scope: 'worker', auto: true },
],
autoLogin: [false, { option: true }],
appConfig: [withDefaultAppConfig({}), { option: true }],
app: async ({ autoLogin, appConfig, username }, use, testInfo) => {
@@ -145,14 +234,15 @@ export const test = fixtureBase.extend<{
}
const creds = lookupCreds(username);
await using temp = await fs.mkdtempDisposable(
path.join(os.tmpdir(), 'connect-e2e-test-')
);
// Start from a copy of the user's logged-in session rather than driving the login UI per test. The test's
// own appConfig is applied on top, since the snapshot carries the default one.
await using temp = autoLogin
? await loggedInDataDir(username, creds)
: await fs.mkdtempDisposable(path.join(os.tmpdir(), 'connect-e2e-test-'));
const { appConfigPath } = await initializeDataDir(temp.path, appConfig);
await using launchedApp = await launchApp(temp.path, creds);
if (autoLogin) {
await login(launchedApp.page, username, creds.password);
}
await use({
electronApp: launchedApp.electronApp,
userDataDir: temp.path,
+2
View File
@@ -34,6 +34,8 @@ export type UserCredentials = {
password: string;
webauthnPrivateKey: string;
webauthnCredentialId: string;
// Assigned by the runner so each user's login gets its own rate limiter bucket.
clientIp: string;
};
export const users: Record<string, UserCredentials> = JSON.parse(
+11 -3
View File
@@ -122,15 +122,23 @@ interface LoginFinishResponse {
export async function directLogin(
startUrl: string,
username: string,
password: string
password: string,
clientIp?: string
): Promise<StorageState> {
const url = new URL(startUrl);
// Claims this user's own bucket in the proxy's rate limiter, so bootstrapping N users doesn't drain the
// budget the tests then need.
const headers: Record<string, string> = {
'content-type': 'application/json',
...(clientIp ? { 'x-forwarded-for': clientIp } : {}),
};
const beginRes = await undiciFetch(
new URL('/v1/webapi/mfa/login/begin', url).toString(),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
headers,
body: JSON.stringify({
passwordless: false,
user: username,
@@ -163,7 +171,7 @@ export async function directLogin(
new URL('/v1/webapi/mfa/login/finishsession', url).toString(),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
headers,
body: JSON.stringify({
user: username,
webauthnAssertionResponse: assertion,
+20 -11
View File
@@ -20,9 +20,10 @@ 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 type { StorageState } from './login';
import { PlayerPage } from './pages/Player';
import { RecordingsPage } from './pages/Recordings';
import { RolesPage } from './pages/Roles';
@@ -104,6 +105,20 @@ export interface LoginAsResult {
}
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 }],
@@ -140,8 +155,6 @@ export const test = base.extend<E2EFixtures>({
);
}
const browser = testInfo.project.name.split(':')[0];
await use(async (index: number): Promise<LoginAsResult> => {
const definition = users[index];
if (!definition) {
@@ -160,9 +173,7 @@ export const test = base.extend<E2EFixtures>({
);
}
const state: StorageState = JSON.parse(
readFileSync(join(authDir, `${browser}-${name}.json`), 'utf-8')
);
const state = await authStateFor(name);
const ctx = page.context();
await ctx.clearCookies();
@@ -214,8 +225,8 @@ export const test = base.extend<E2EFixtures>({
},
storageState: async ({ username }, use, testInfo) => {
// Connect tests drive login through the Electron app, and unauthenticated
// tests intentionally start without a session, so neither has a storage
// state file to load even when the test declares a user.
// 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' ||
@@ -225,9 +236,7 @@ export const test = base.extend<E2EFixtures>({
return;
}
const browser = testInfo.project.name.split(':')[0];
await use(join(authDir, `${browser}-${username}.json`));
await use(await authStateFor(username));
},
unifiedResourcesPage: async ({ page }, use) => {
await use(new UnifiedResourcesPage(page));
+2 -9
View File
@@ -16,8 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
// Default to localhost:3080/web/login if START_URL is not defined.
@@ -38,7 +36,8 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
globalSetup: fileURLToPath(new URL('global-setup.ts', import.meta.url)),
// No globalSetup: sessions are minted per user on first use (helpers/authState.ts) so the logins spread
// across the run instead of drawing the whole run's rate limiter budget at once.
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
@@ -62,12 +61,6 @@ export default defineConfig({
name: `${browser}:unauthenticated`,
testDir: './tests/web/unauthenticated',
use: { ...browserDevices[browser] },
// TODO(ryan): fix these tests
// Unauthenticated Firefox tests sometimes get rate-limited and sometimes think the cluster name is
// `localhost`, for reasons I haven't been able to figure out, so don't run them for now. The project
// stays defined because the runner selects projects by exact name, and Playwright fails selection
// outright on an unknown one.
...(browser === 'firefox' ? { testIgnore: /.*/ } : {}),
},
]),
+5 -1
View File
@@ -57,6 +57,7 @@ type credentialsJSON struct {
Password string `json:"password"`
WebauthnPrivateKey string `json:"webauthnPrivateKey"`
WebauthnCredentialId string `json:"webauthnCredentialId"`
ClientIP string `json:"clientIp"`
}
// readRoleFile reads e2eDir/testdata/roles/<filename> and extracts
@@ -247,7 +248,7 @@ func buildBootstrapState(e2eDir string, scannedUsers []scannedUser) (*bootstrapR
// Track custom role files already loaded to deduplicate.
customRolesByFile := make(map[string]*customRole)
for _, g := range groups {
for i, g := range groups {
su := g.su
name := nameGen.Generate()
userMapping[g.key] = name
@@ -257,6 +258,8 @@ func buildBootstrapState(e2eDir string, scannedUsers []scannedUser) (*bootstrapR
return nil, fmt.Errorf("generating credentials for %s: %w", name, err)
}
userCredentials.clientIP = assignClientIP(i)
creds[name] = userCredentials
traits := su.traits
@@ -341,6 +344,7 @@ func writeCredentialsFile(path string, creds map[string]*credentials) error {
Password: c.password,
WebauthnPrivateKey: c.privateKeyPKCS8Base64,
WebauthnCredentialId: c.credentialIDBase64,
ClientIP: c.clientIP,
}
}
+45
View File
@@ -20,6 +20,7 @@ package main
import (
"encoding/json"
"net/netip"
"os"
"path/filepath"
"strings"
@@ -557,3 +558,47 @@ func TestWriteCredentialsFile(t *testing.T) {
t.Errorf("webauthnCredentialId = %q, want %q", entry.WebauthnCredentialId, "cid-1")
}
}
func TestAssignClientIP(t *testing.T) {
tests := []struct {
name string
index int
want string
}{
{name: "first", index: 0, want: "fd00:e2e:1:0::0"},
{name: "last of group", index: 0xffff, want: "fd00:e2e:1:0::ffff"},
{name: "rolls into next group", index: 0x10000, want: "fd00:e2e:1:1::0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := assignClientIP(tt.index)
if got != tt.want {
t.Errorf("assignClientIP(%d) = %q, want %q", tt.index, got, tt.want)
}
})
}
}
// A malformed address makes the proxy reject the request outright rather than bucketing it, so every generated
// address has to parse as IPv6.
func TestAssignClientIPIsUniqueIPv6(t *testing.T) {
seen := make(map[string]int, 1000)
for i := range 1000 {
got := assignClientIP(i)
addr, err := netip.ParseAddr(got)
if err != nil {
t.Fatalf("assignClientIP(%d) = %q, not a valid address: %v", i, got, err)
}
if addr.Is4() {
t.Fatalf("assignClientIP(%d) = %q, want IPv6", i, got)
}
if prev, ok := seen[got]; ok {
t.Fatalf("assignClientIP(%d) = %q, already assigned to index %d", i, got, prev)
}
seen[got] = i
}
}
+2 -8
View File
@@ -317,9 +317,8 @@ func (p *playwrightRunner) codegen(ctx context.Context) error {
return p.openWebAuthenticated(ctx, "codegen")
}
// openWebAuthenticated runs the global setup to generate auth state, then opens
// a Chromium browser with a virtual WebAuthn authenticator pre-loaded so that
// MFA challenges resolve automatically.
// openWebAuthenticated opens a Chromium browser logged in as the default user, with a virtual WebAuthn
// authenticator pre-loaded so that MFA challenges resolve automatically.
func (p *playwrightRunner) openWebAuthenticated(ctx context.Context, playwrightCmd string) error {
if len(p.config.instances) == 0 {
return fmt.Errorf("no test instances configured")
@@ -336,11 +335,6 @@ func (p *playwrightRunner) openWebAuthenticated(ctx context.Context, playwrightC
return err
}
slog.DebugContext(ctx, "running global setup to generate auth state")
if err := p.pnpm(ctx, []string{"exec", "tsx", filepath.Join(p.config.sharedDir, "global-setup.ts")}, env); err != nil {
return err
}
slog.InfoContext(ctx, "opening playwright with auth and WebAuthn", "command", playwrightCmd)
return p.pnpm(ctx, []string{
+13
View File
@@ -38,6 +38,19 @@ type credentials struct {
credentialIDBase64 string
publicKeyCBORBase64 string
privateKeyPKCS8Base64 string
// clientIP is the X-Forwarded-For this user's setup login claims, giving it its own rate limiter bucket.
clientIP string
}
// Addresses have to be IPv6: the proxy signs a PROXY header pairing this source with the destination of the
// browser's own connection, which is ::1 because the tests reach the proxy over localhost, and signing rejects
// a version mismatch. Playwright claims fd00:e2e:2:: for per-test addresses.
const userIPPrefix = "fd00:e2e:1"
// assignClientIP maps a bootstrap list position to a unique address.
func assignClientIP(index int) string {
return fmt.Sprintf("%s:%x::%x", userIPPrefix, index/0x10000, index%0x10000)
}
// generateUserCredentials creates a fresh password, bcrypt hash, and ECDSA key pair
+11 -7
View File
@@ -22,9 +22,6 @@
//
// Usage: pnpm exec tsx scripts/open-with-webauthn.ts <codegen|open> <url>
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
chromium,
firefox,
@@ -32,7 +29,9 @@ import {
type BrowserContext,
} from '@playwright/test';
import { authStateFor } from '../helpers/authState';
import { defaultUsername } from '../helpers/defaultUser';
import type { StorageState } from '../helpers/login';
import { mockWebAuthn } from '../helpers/webauthn';
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
@@ -59,20 +58,25 @@ if (!mode || !startURL) {
process.exit(1);
}
const e2eDir =
process.env.E2E_DIR || join(dirname(fileURLToPath(import.meta.url)), '..');
const browserName = (process.env.E2E_BROWSERS || 'chromium').split(',')[0];
const browserTypes = { chromium, firefox, webkit };
const browserType =
browserTypes[browserName as keyof typeof browserTypes] ?? chromium;
const username = defaultUsername();
const storageStatePath = join(e2eDir, `.auth/${browserName}-${username}.json`);
// Runs against an existing cluster have no bootstrapped credentials to log in with, so open without a session
// rather than failing.
let storageState: StorageState | undefined;
if (process.env.E2E_USERS_FILE) {
storageState = await authStateFor(username);
info(`logged in as ${bold(username)}`);
}
info(`launching ${browserName} ${dim(`(mode: ${mode})`)}`);
const browser = await browserType.launch({ headless: false });
const context = await browser.newContext({
storageState: storageStatePath,
storageState,
ignoreHTTPSErrors: true,
viewport: null,
});
+4 -9
View File
@@ -24,10 +24,7 @@ import { UnifiedResourcesPage } from '@gravitational/e2e/helpers/pages/UnifiedRe
test.use({
autoLogin: true,
fixtures: [
// TODO(gzdunek): Uncomment when enabling these tests.
/*'kube'*/
],
fixtures: ['kube'],
user: {
roles: ['access'],
traits: { kubernetes_groups: ['system:masters'] },
@@ -45,9 +42,7 @@ async function openKubeTerminal(
await terminal.waitForText(kubePromptText);
}
// TODO(gzdunek): Re-enable these tests once Connect E2E logins reuse authenticated state
// instead of hitting the rate limiter.
test.skip('creates kubeconfig under user data and reuses for the same kube session', async ({
test('creates kubeconfig under user data and reuses for the same kube session', async ({
app,
}) => {
const { page, userDataDir } = app;
@@ -74,7 +69,7 @@ test.skip('creates kubeconfig under user data and reuses for the same kube sessi
expect(pathBeforeTabClose).toBe(pathAfterTabReopen);
});
test.skip('closing connection removes kubeconfig file', async ({ app }) => {
test('closing connection removes kubeconfig file', async ({ app }) => {
const { page } = app;
const resources = new UnifiedResourcesPage(page);
const terminal = new TerminalPage(page);
@@ -100,7 +95,7 @@ test.skip('closing connection removes kubeconfig file', async ({ app }) => {
await expect.poll(() => fileExists(kubeconfigPath)).toBe(false);
});
test.skip('exec into a pod', async ({ app }) => {
test('exec into a pod', async ({ app }) => {
const { page } = app;
const resources = new UnifiedResourcesPage(page);
const terminal = new TerminalPage(page);
+3 -40
View File
@@ -24,6 +24,7 @@ import {
expect,
initializeDataDir,
launchApp,
loggedInDataDir,
login,
test,
withDefaultAppConfig,
@@ -33,48 +34,10 @@ import { startUrl } from '@gravitational/e2e/helpers/env';
// These tests manage the app lifecycle manually (multiple launches/closes), so they do not use the
// `app` fixture.
test.describe('state restoration from disk', () => {
// Run all tests in this block sequentially in a single worker so that beforeAll runs exactly
// once, not once per test (which fullyParallel would otherwise cause).
// Run all tests in this block sequentially in a single worker, since fullyParallel would otherwise
// give each its own worker and its own login.
test.describe.configure({ mode: 'serial' });
// A snapshot of a data dir with a logged-in session, created once in beforeAll. Tests that need
// logged-in state copy this snapshot instead of calling login again, keeping total login calls to
// a minimum (important to avoid rate-limiting when running full Connect suite).
let loggedInSnapshotPath: string;
test.beforeAll(async () => {
loggedInSnapshotPath = await fs.mkdtemp(
path.join(os.tmpdir(), 'connect-e2e-state-snapshot-')
);
await initializeDataDir(loggedInSnapshotPath, withDefaultAppConfig({}));
// Login once to populate the data dir with session state.
{
await using app = await launchApp(loggedInSnapshotPath);
await login(app.page);
}
});
test.afterAll(async () => {
await fs.rm(loggedInSnapshotPath, { recursive: true, force: true });
});
/**
* Creates a fresh disposable temp dir pre-populated with the logged-in snapshot.
*/
async function loggedInDataDir() {
const temp = await fs.mkdtempDisposable(
path.join(os.tmpdir(), 'connect-e2e-state-')
);
try {
await fs.cp(loggedInSnapshotPath, temp.path, { recursive: true });
} catch (error) {
temp.remove();
throw error;
}
return temp;
}
test('relaunch restores tabs', async () => {
await using temp = await loggedInDataDir();
@@ -23,14 +23,11 @@ const textToCopy = 'text-to-copy';
// Text that is pre-populated in the clipboard before the test's copy attempt
const preCopiedText = 'pre-copied-text';
test.describe('web terminal clipboard mode', () => {
// The clipboard-read/clipboard-write permissions and readText() method
// needed for this test only work on Chromium, so only run this test for Chromium.
test.skip(
({ browserName }) => browserName !== 'chromium',
'clipboard permissions and readText() are Chromium-only in Playwright'
);
// The clipboard-read/clipboard-write permissions and readText() method
// needed for this test only work on Chromium, so only run this test for Chromium.
test.use({ browsers: ['chromium'] });
test.describe('web terminal clipboard mode', () => {
test.use({
fixtures: ['ssh-node'],
permissions: ['clipboard-read', 'clipboard-write'],
@@ -30,10 +30,6 @@ function username(testInfo: TestInfo) {
}
test('switching between dark and light theme', async ({ page }, testInfo) => {
// The re-login below can trip Teleport's challenge-generation rate limiter,
// so allow extra time to retry it.
test.slow();
await signup(page, username(testInfo), defaultPassword);
await expect(page.locator('body')).toHaveCSS('background-color', lightBody);
@@ -57,12 +53,7 @@ test('switching between dark and light theme', async ({ page }, testInfo) => {
// signing in on a fresh browser.
await page.context().clearCookies();
await page.evaluate(() => localStorage.clear());
// Logging back in right after signup can trip Teleport's challenge-generation
// rate limiter ("rate limit exceeded, try again in Ns"); retry until it lets
// us through.
await expect(async () => {
await login(page, username(testInfo), defaultPassword);
}).toPass({ timeout: 30_000, intervals: [3_000] });
await login(page, username(testInfo), defaultPassword);
await expect(page.locator('body')).toHaveCSS('background-color', darkBody);
// Switch to light theme.
@@ -24,9 +24,6 @@ test.use({ user: { roles: ['access', 'editor'] } });
test('verify that a user can log in with username, password, and webauthn', async ({
page,
username,
browserName,
}) => {
// TODO(ryan): figure out a fix for firefox flakiness
test.skip(browserName === 'firefox', 'flaky on firefox');
await login(page, username);
});
+7 -18
View File
@@ -25,14 +25,9 @@ function username(testInfo: TestInfo) {
return `testuser-${testInfo.workerIndex}`;
}
// TODO(ryan): re-enable this test once Firefox flakiness is resolved.
test.skip('verify that a user can sign up with webauthn and login', async ({
test('verify that a user can sign up with webauthn and login', async ({
page,
}, testInfo) => {
// Signing up auto-logs-in and we then log straight back in; that burst can
// trip Teleport's challenge-generation rate limiter, so allow time to retry.
test.slow();
await signup(page, username(testInfo));
await page.getByRole('button', { name: 'User Menu' }).click();
@@ -43,18 +38,12 @@ test.skip('verify that a user can sign up with webauthn and login', async ({
await page.getByRole('textbox', { name: 'Username' }).press('Tab');
await page.getByRole('textbox', { name: 'Password' }).fill('passwordtest123');
// The web login challenge endpoint is rate limited; signing up then logging
// back in immediately can trip it ("rate limit exceeded, try again in Ns").
// Retry the sign-in until the limiter lets it through.
await expect(async () => {
await page
.getByTestId('userpassword')
.getByRole('button', { name: 'Sign In' })
.click();
await expect(page.getByRole('heading', { name: 'Resources' })).toBeVisible({
timeout: 5_000,
});
}).toPass({ timeout: 30_000, intervals: [3_000] });
await page
.getByTestId('userpassword')
.getByRole('button', { name: 'Sign In' })
.click();
await expect(page.getByRole('heading', { name: 'Resources' })).toBeVisible();
});
// Clean the user before each attempt (and after) so retries start clean rather