mirror of
https://github.com/gravitational/teleport.git
synced 2026-08-28 21:12:20 +08:00
Connect: add e2e tests for headless auth (#64837)
* Switch to `ESNext` to allow using `Promise.withResolvers` * Add missing accessibility specifiers * Enable running Web UI in Connect tests * Add tests for headless auth * `aria-label` -> `title` * Improve resource management with `AsyncDisposableStack.move` * Pass `AbortError` * Add comment about timeout
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 { expect } from '@gravitational/e2e/helpers/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export class HeadlessAuthDialogPage {
|
||||
private readonly dialog;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.dialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Headless command on' });
|
||||
}
|
||||
|
||||
async waitForVisible() {
|
||||
await expect(this.dialog).toBeVisible();
|
||||
}
|
||||
|
||||
async waitForClose() {
|
||||
await expect(this.dialog).toBeHidden();
|
||||
}
|
||||
|
||||
async waitForRequestId(requestId: string) {
|
||||
await expect(this.dialog).toContainText(requestId);
|
||||
}
|
||||
|
||||
async approve() {
|
||||
await this.waitForVisible();
|
||||
await this.dialog.getByRole('button', { name: 'Approve' }).click();
|
||||
}
|
||||
|
||||
async reject() {
|
||||
await this.waitForVisible();
|
||||
await this.dialog.getByRole('button', { name: 'Reject' }).click();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.waitForVisible();
|
||||
await this.dialog.getByRole('button', { name: /close/i }).click();
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,8 @@ export default defineConfig({
|
||||
|
||||
{
|
||||
name: 'connect',
|
||||
// Enables interacting with Web UI from Connect test flows.
|
||||
use: browserDevices.chromium,
|
||||
testDir: './tests/connect',
|
||||
workers: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* 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 { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { test, expect } from '@gravitational/e2e/helpers/connect';
|
||||
import { connectTshBin, startUrl } from '@gravitational/e2e/helpers/env';
|
||||
import { login as webLogin } from '@gravitational/e2e/helpers/login';
|
||||
import { HeadlessAuthDialogPage } from '@gravitational/e2e/helpers/pages/connect/HeadlessAuthDialog';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const REQUEST_URL_RE = /https?:\/\/\S+\/web\/headless\/[0-9a-f-]+/i;
|
||||
const HEADLESS_USER = 'bob';
|
||||
|
||||
type CreatedRequest = {
|
||||
url: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type HeadlessRequestProcess = {
|
||||
request: CreatedRequest;
|
||||
// Throws if process exits with non-zero exit code.
|
||||
waitForExit(): Promise<void>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
};
|
||||
|
||||
async function startHeadlessRequestProcess(options?: {
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<HeadlessRequestProcess> {
|
||||
const proxyHost = new URL(startUrl).host;
|
||||
|
||||
await using disposer = new AsyncDisposableStack();
|
||||
const homeDir = disposer.use(
|
||||
await fs.mkdtempDisposable(path.join(os.tmpdir(), 'headless-e2e-'))
|
||||
);
|
||||
const child: ChildProcessWithoutNullStreams = disposer.use(
|
||||
spawn(
|
||||
connectTshBin,
|
||||
[
|
||||
'ls',
|
||||
'--headless',
|
||||
'--insecure',
|
||||
`--user=${HEADLESS_USER}`,
|
||||
`--proxy=${proxyHost}`,
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
TELEPORT_HOME: homeDir.path,
|
||||
},
|
||||
signal: options?.abortSignal,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
let output = '';
|
||||
const requestCreated = Promise.withResolvers<CreatedRequest>();
|
||||
const exited = Promise.withResolvers<void>();
|
||||
|
||||
child.stderr.on('data', chunk => {
|
||||
output += chunk.toString('utf8');
|
||||
const match = output.match(REQUEST_URL_RE);
|
||||
if (match) {
|
||||
const url = match[0];
|
||||
const id = new URL(url).pathname.split('/').at(-1);
|
||||
if (!id) {
|
||||
requestCreated.reject(
|
||||
new Error(`could not parse request id from URL: ${url}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
requestCreated.resolve({ url, id });
|
||||
}
|
||||
});
|
||||
|
||||
child.once('exit', code => {
|
||||
if (code !== 0) {
|
||||
exited.reject(new Error(`Process exited with non zero code: ${output}`));
|
||||
return;
|
||||
}
|
||||
exited.resolve();
|
||||
});
|
||||
|
||||
child.once('error', error => exited.reject(error));
|
||||
|
||||
const request = await Promise.race([
|
||||
requestCreated.promise,
|
||||
exited.promise.then(() => {
|
||||
throw new Error(
|
||||
`Process exited before creating request. Output:\n${output}`
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
const disposables = disposer.move();
|
||||
return {
|
||||
request,
|
||||
waitForExit: () => exited.promise,
|
||||
[Symbol.asyncDispose]: () => disposables.disposeAsync(),
|
||||
};
|
||||
}
|
||||
|
||||
async function approveInWebUi(requestUrl: string) {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
baseURL: startUrl,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await webLogin(page);
|
||||
await page.goto(requestUrl);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
const passkeyButton = page.getByRole('button', {
|
||||
name: 'Passkey or MFA Device',
|
||||
});
|
||||
await passkeyButton.click();
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
test.use({ autoLogin: true });
|
||||
// Increase timeout because this test runs several sub-steps in one flow.
|
||||
test.setTimeout(30_000);
|
||||
|
||||
test('headless auth modal flows', async ({ app }) => {
|
||||
const { page } = app;
|
||||
const headlessDialog = new HeadlessAuthDialogPage(page);
|
||||
|
||||
await test.step('approve, reject, and ignore then approve in Web UI', async () => {
|
||||
await using process1 = await startHeadlessRequestProcess();
|
||||
await headlessDialog.approve();
|
||||
await headlessDialog.waitForClose();
|
||||
await expect(process1.waitForExit()).resolves.toBeUndefined();
|
||||
|
||||
await using process2 = await startHeadlessRequestProcess();
|
||||
const exitRejected = expect(process2.waitForExit()).rejects.toThrow(
|
||||
/headless authentication denied/i
|
||||
);
|
||||
await headlessDialog.reject();
|
||||
await headlessDialog.waitForClose();
|
||||
await exitRejected;
|
||||
|
||||
await using process3 = await startHeadlessRequestProcess();
|
||||
await headlessDialog.close();
|
||||
await headlessDialog.waitForClose();
|
||||
await approveInWebUi(process3.request.url);
|
||||
await expect(process3.waitForExit()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
await test.step('canceling a headless command closes the modal automatically', async () => {
|
||||
const abortController = new AbortController();
|
||||
await using process = await startHeadlessRequestProcess({
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
await headlessDialog.waitForVisible();
|
||||
|
||||
abortController.abort();
|
||||
await headlessDialog.waitForClose();
|
||||
await expect(process.waitForExit()).rejects.toThrow(
|
||||
'The operation was aborted'
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('approving a headless command in Web UI closes the modal automatically', async () => {
|
||||
await using process = await startHeadlessRequestProcess();
|
||||
await headlessDialog.waitForVisible();
|
||||
|
||||
await approveInWebUi(process.request.url);
|
||||
await expect(process.waitForExit()).resolves.toBeUndefined();
|
||||
await headlessDialog.waitForClose();
|
||||
});
|
||||
|
||||
await test.step('shows the second concurrent request after closing the first modal', async () => {
|
||||
await using process1 = await startHeadlessRequestProcess();
|
||||
await using process2 = await startHeadlessRequestProcess();
|
||||
|
||||
const requestId1 = process1.request.id;
|
||||
const requestId2 = process2.request.id;
|
||||
const requestsById: Record<string, HeadlessRequestProcess> = {
|
||||
[requestId1]: process1,
|
||||
[requestId2]: process2,
|
||||
};
|
||||
|
||||
const firstVisibleRequestId = await Promise.any([
|
||||
headlessDialog.waitForRequestId(requestId1).then(() => requestId1),
|
||||
headlessDialog.waitForRequestId(requestId2).then(() => requestId2),
|
||||
]);
|
||||
const secondVisibleRequestId =
|
||||
firstVisibleRequestId === requestId1 ? requestId2 : requestId1;
|
||||
|
||||
await headlessDialog.approve();
|
||||
await headlessDialog.waitForClose();
|
||||
await requestsById[firstVisibleRequestId].waitForExit();
|
||||
|
||||
await headlessDialog.waitForRequestId(secondVisibleRequestId);
|
||||
await headlessDialog.approve();
|
||||
await headlessDialog.waitForClose();
|
||||
await requestsById[secondVisibleRequestId].waitForExit();
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
|
||||
@@ -45,6 +45,7 @@ export const Dialog = forwardRef<
|
||||
*/}
|
||||
<DialogBox
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
data-testid="dialogbox"
|
||||
dialogCss={dialogCss}
|
||||
className={className}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function HeadlessPrompt({
|
||||
</H2>
|
||||
<ButtonIcon
|
||||
type="button"
|
||||
title="Close"
|
||||
color="text.slightlyMuted"
|
||||
onClick={() => {
|
||||
abortApproval();
|
||||
|
||||
Reference in New Issue
Block a user