Connect: prepare for E2E testing (#64613)

* Allow storing `home`, `userData`, and `sessionData` under custom path

* Optimize `getRuntimeSettings` loading sequence

* Enable mocking webauthn in tests

* Rename `webauthnmock_off.go` to `webauthnmock_nop.go`
This commit is contained in:
Grzegorz Zdunek
2026-03-16 15:55:02 +00:00
committed by GitHub
parent 8ad0912b30
commit 56b38da8dc
6 changed files with 177 additions and 36 deletions
+13
View File
@@ -49,6 +49,9 @@ type Config struct {
InstallationID string
// AddKeysToAgent is passed to [client.Config].
AddKeysToAgent string
// WebauthnLogin allows tests to override the Webauthn Login func.
// Defaults to wancli.Login.
WebauthnLogin client.WebauthnLoginFunc
// HardwareKeyAgent determines whether the daemon will run the hardware key agent.
HardwareKeyAgent bool
}
@@ -92,5 +95,15 @@ func (c *Config) CheckAndSetDefaults() error {
c.AddKeysToAgent = client.AddKeysToAgentAuto
}
if c.WebauthnLogin == nil {
// Enables WebAuthn mocking for E2E tests.
// Requires the `webauthnmock` build tag and must never be enabled in production builds.
webauthnLogin, err := webauthnLoginMock()
if err != nil {
return trace.Wrap(err)
}
c.WebauthnLogin = webauthnLogin
}
return nil
}
+1
View File
@@ -64,6 +64,7 @@ func Serve(ctx context.Context, cfg Config) error {
Clock: clock,
InsecureSkipVerify: cfg.InsecureSkipVerify,
AddKeysToAgent: cfg.AddKeysToAgent,
WebauthnLogin: cfg.WebauthnLogin,
ClientStore: client.NewFSClientStore(cfg.HomeDir, client.WithHardwareKeyService(hwks)),
})
if err != nil {
+90
View File
@@ -0,0 +1,90 @@
//go:build webauthnmock
/*
* 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/>.
*/
package teleterm
import (
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"os"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/lib/auth/mocku2f"
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
"github.com/gravitational/teleport/lib/client"
)
const (
privateKeyEnv = "E2E_WEBAUTHN_PRIVATE_KEY"
credentialIDEnv = "E2E_WEBAUTHN_CREDENTIAL_ID"
)
// In builds with the webauthnmock tag, provide a WebAuthn login mock for E2E tests.
func webauthnLoginMock() (client.WebauthnLoginFunc, error) {
privateKeyB64 := os.Getenv(privateKeyEnv)
credentialIDB64 := os.Getenv(credentialIDEnv)
if privateKeyB64 == "" || credentialIDB64 == "" {
return nil, trace.BadParameter("both %s and %s must be provided in webauthnmock mode", privateKeyEnv, credentialIDEnv)
}
privateKeyPKCS8, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, trace.Wrap(err, "decoding %s", privateKeyEnv)
}
credentialID, err := base64.StdEncoding.DecodeString(credentialIDB64)
if err != nil {
return nil, trace.Wrap(err, "decoding %s", credentialIDEnv)
}
keyAny, err := x509.ParsePKCS8PrivateKey(privateKeyPKCS8)
if err != nil {
return nil, trace.Wrap(err, "parsing %s", privateKeyEnv)
}
privateKey, ok := keyAny.(*ecdsa.PrivateKey)
if !ok {
return nil, trace.BadParameter("%s must contain an ECDSA private key, got %T", privateKeyEnv, keyAny)
}
device, err := mocku2f.CreateWithKeyHandle(credentialID)
if err != nil {
return nil, trace.Wrap(err, "creating mock WebAuthn device")
}
device.PrivateKey = privateKey
device.SetUV = true
return func(ctx context.Context, origin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, opts *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
car, err := device.SignAssertion(origin, assertion)
if err != nil {
return nil, "", trace.Wrap(err)
}
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Webauthn{
Webauthn: wantypes.CredentialAssertionResponseToProto(car),
},
}, "", nil
}, nil
}
+28
View File
@@ -0,0 +1,28 @@
//go:build !webauthnmock
/*
* 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/>.
*/
package teleterm
import "github.com/gravitational/teleport/lib/client"
// In builds without the webauthnmock tag (default), no WebAuthn login mock is provided.
func webauthnLoginMock() (client.WebauthnLoginFunc, error) {
return nil, nil
}
+9 -3
View File
@@ -135,7 +135,12 @@ async function initializeApp(): Promise<void> {
// TODO(gzdunek): DELETE IN 20.0.0. Users should already migrate to the new location.
// Also remove TshHomeMigrationBanner component, relevant properties from app_state.json,
// and address the TODO in teleport-connect.mdx > ##Troubleshooting.
await migrateOldTshHomeOnce(logger, tshHome, appStateFileStorage);
await migrateOldTshHomeOnce(
logger,
tshHome,
appStateFileStorage,
settings.userDataDir
);
let mainProcess: MainProcess;
try {
@@ -371,9 +376,10 @@ function showDialogWithError(title: string, unknownError: unknown) {
async function migrateOldTshHomeOnce(
logger: Logger,
tshHome: string,
appStorage: FileStorage
appStorage: FileStorage,
userDataDir: string
): Promise<void> {
const oldTshHome = path.resolve(app.getPath('userData'), 'tsh');
const oldTshHome = path.resolve(userDataDir, 'tsh');
const tshHomeMigrationKey = 'tshHomeMigration';
const tshMigration: TshHomeMigration =
appStorage.get()?.[tshHomeMigrationKey];
@@ -16,9 +16,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { app } from 'electron';
@@ -32,6 +32,9 @@ const RESOURCES_PATH = app.isPackaged
? process.resourcesPath
: path.join(__dirname, '../../..');
// Optional root directory for app data; when set, Connect stores `home`, `userData`, and `sessionData` under this path.
// Used in e2e tests.
const CONNECT_DATA_DIR_ENV_VAR = 'CONNECT_DATA_DIR';
const TSH_BIN_ENV_VAR = 'CONNECT_TSH_BIN_PATH';
// __dirname of this file in dev mode is teleport/web/packages/teleterm/build/app/main
// We default to teleport/build/tsh.
@@ -57,37 +60,45 @@ const insecure =
(dev && !!env.CONNECT_INSECURE);
export async function getRuntimeSettings(): Promise<RuntimeSettings> {
const homeDir = app.getPath('home');
const userDataDir = app.getPath('userData');
const sessionDataDir = app.getPath('sessionData');
const envDataDir = process.env[CONNECT_DATA_DIR_ENV_VAR];
const homeDir = envDataDir
? path.join(envDataDir, 'home')
: app.getPath('home');
const userDataDir = envDataDir
? path.join(envDataDir, 'userData')
: app.getPath('userData');
const sessionDataDir = envDataDir
? path.join(envDataDir, 'sessionData')
: app.getPath('sessionData');
const tempDataDir = app.getPath('temp');
const {
tsh: tshAddress,
shared: sharedAddress,
tshdEvents: tshdEventsAddress,
} = await requestGrpcServerAddresses();
const [grpcAddresses, kubeConfigsDir, certsDir, availableShells] =
await Promise.all([
requestGrpcServerAddresses(),
getKubeConfigsDir(userDataDir),
getCertsDir(userDataDir),
getAvailableShells(),
]);
const { binDir, tshBinPath } = getBinaryPaths();
const { username } = os.userInfo();
const hostname = os.hostname();
const kubeConfigsDir = getKubeConfigsDir();
// TODO(ravicious): Replace with app.getPath('logs'). We started storing logs under a custom path.
// Before switching to the recommended path, we need to investigate the impact of this change.
// https://www.electronjs.org/docs/latest/api/app#appgetpathname
const logsDir = path.join(userDataDir, 'logs');
const installationId = loadInstallationId(
path.resolve(app.getPath('userData'), 'installation_id')
path.resolve(userDataDir, 'installation_id')
);
const tshd = {
binaryPath: tshBinPath,
defaultHomeDir: path.resolve(app.getPath('home'), '.tsh'),
requestedNetworkAddress: tshAddress,
defaultHomeDir: path.resolve(homeDir, '.tsh'),
requestedNetworkAddress: grpcAddresses.tsh,
};
const sharedProcess = {
requestedNetworkAddress: sharedAddress,
requestedNetworkAddress: grpcAddresses.shared,
};
const tshdEvents = {
requestedNetworkAddress: tshdEventsAddress,
requestedNetworkAddress: grpcAddresses.tshdEvents,
};
// To start the app in dev mode, we run `electron path_to_main.js`. It means
@@ -98,7 +109,6 @@ export async function getRuntimeSettings(): Promise<RuntimeSettings> {
//
// A workaround is to read the version from `process.env.npm_package_version`.
const appVersion = dev ? process.env.npm_package_version : app.getVersion();
const availableShells = await getAvailableShells();
return {
dev,
@@ -113,7 +123,7 @@ export async function getRuntimeSettings(): Promise<RuntimeSettings> {
tempDataDir,
binDir,
agentBinaryPath: path.resolve(sessionDataDir, 'teleport', 'teleport'),
certsDir: getCertsDir(),
certsDir,
availableShells,
defaultOsShellId: getDefaultShell(availableShells),
kubeConfigsDir,
@@ -129,23 +139,16 @@ export async function getRuntimeSettings(): Promise<RuntimeSettings> {
};
}
function getCertsDir() {
const certsPath = path.resolve(app.getPath('userData'), 'certs');
if (!fs.existsSync(certsPath)) {
fs.mkdirSync(certsPath);
}
if (fs.readdirSync(certsPath)) {
fs.rmSync(certsPath, { force: true, recursive: true });
fs.mkdirSync(certsPath);
}
async function getCertsDir(userDataDir: string): Promise<string> {
const certsPath = path.resolve(userDataDir, 'certs');
await fs.promises.rm(certsPath, { recursive: true, force: true });
await fs.promises.mkdir(certsPath, { recursive: true });
return certsPath;
}
function getKubeConfigsDir(): string {
const kubeConfigsPath = path.resolve(app.getPath('userData'), 'kube');
if (!fs.existsSync(kubeConfigsPath)) {
fs.mkdirSync(kubeConfigsPath);
}
async function getKubeConfigsDir(userDataDir: string): Promise<string> {
const kubeConfigsPath = path.resolve(userDataDir, 'kube');
await fs.promises.mkdir(kubeConfigsPath, { recursive: true });
return kubeConfigsPath;
}