mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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: /.*/ } : {}),
|
||||
},
|
||||
]),
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user