This is an automated email from the ASF dual-hosted git repository.
voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new c6f58f8ca1 [ZEPPELIN-6681] Cover login route authentication and
failure recovery with E2E tests
c6f58f8ca1 is described below
commit c6f58f8ca1548ce1fd7edaafa201fe6d5726b4f5
Author: Minho Jang <[email protected]>
AuthorDate: Wed Nov 4 21:01:52 2026 +0900
[ZEPPELIN-6681] Cover login route authentication and failure recovery with
E2E tests
### What is this PR for?
Extend login-route E2E coverage for authentication handoff and failure
recovery.
Successful login now verifies the login response, the browser-visible home
page, a session cookie, and a ticket request using the same browser session.
The suite also checks that an already-authenticated visitor is redirected home
without another login submission.
Invalid-credential, network-failure, and HTTP 503 scenarios verify that the
form exits its loading state, remains editable, and accepts a successful retry.
Only the network and 503 failures are injected; successful authentication
always uses the live server.
### What type of PR is it?
Improvement
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6681
### How should this be tested?
Start a Shiro-enabled Zeppelin backend at `http://localhost:8080`. Its
Shiro configuration must include an account with the `admin` role. Ensure
`conf/shiro.ini` matches the server configuration, or set
`ZEPPELIN_E2E_SHIRO_INI` to that file.
With frontend dependencies installed, run from `zeppelin-web-angular`:
```bash
npm run e2e:fast -- tests/login/login.spec.ts --workers=1 --retries=0
```
For all configured browsers:
```bash
npm run e2e -- tests/login/login.spec.ts --workers=1 --retries=0
```
Check that:
- Valid credentials establish an authenticated browser session and reach
home.
- An already-authenticated visit to the login route returns home without
another login submission.
- Invalid credentials leave the form usable and nonloading, and corrected
credentials authenticate successfully.
- Network and HTTP 503 failures allow recovery and a successful retry
against the real server.
- Tab moves focus from username to password to the login button.
Run against a Shiro-enabled server; skipped scenarios on an anonymous
server do not verify authentication behavior.
### Questions:
* Does the license files need to update? No.
* Is there breaking changes for older versions? No.
* Does this needs documentation? No.
Closes #5605 from miinhho/ZEPPELIN-6681-login-route-e2e.
Signed-off-by: YONGJAE LEE <[email protected]>
---
zeppelin-web-angular/e2e/models/login-page.ts | 16 +-
zeppelin-web-angular/e2e/tests/login/login.spec.ts | 276 ++++++++++++---------
2 files changed, 165 insertions(+), 127 deletions(-)
diff --git a/zeppelin-web-angular/e2e/models/login-page.ts
b/zeppelin-web-angular/e2e/models/login-page.ts
index 7e89786883..065fd48314 100644
--- a/zeppelin-web-angular/e2e/models/login-page.ts
+++ b/zeppelin-web-angular/e2e/models/login-page.ts
@@ -22,10 +22,10 @@ export class LoginPage extends BasePage {
constructor(page: Page) {
super(page);
- this.userNameInput = page.getByRole('textbox', { name: 'User Name' });
- this.passwordInput = page.getByRole('textbox', { name: 'Password' });
- this.loginButton = page.getByRole('button', { name: 'Login' });
- this.formContainer = page.locator('form[nz-form]');
+ this.userNameInput = page.getByRole('textbox', { name: 'User Name', exact:
true });
+ this.passwordInput = page.getByRole('textbox', { name: 'Password', exact:
true });
+ this.loginButton = page.getByRole('button', { name: 'Login', exact: true
});
+ this.formContainer = page.locator('form').filter({ has: this.userNameInput
});
this.errorMessage = page.locator("text=The username and password that you
entered don't match.").first();
}
@@ -38,12 +38,4 @@ export class LoginPage extends BasePage {
await this.passwordInput.fill(password, { timeout: 15000 });
await this.loginButton.click({ timeout: 15000 });
}
-
- async waitForErrorMessage(): Promise<void> {
- await this.errorMessage.waitFor({ state: 'visible', timeout: 5000 });
- }
-
- async getErrorMessageText(): Promise<string> {
- return this.getElementText(this.errorMessage);
- }
}
diff --git a/zeppelin-web-angular/e2e/tests/login/login.spec.ts
b/zeppelin-web-angular/e2e/tests/login/login.spec.ts
index d76faf4496..64746a9baa 100644
--- a/zeppelin-web-angular/e2e/tests/login/login.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/login/login.spec.ts
@@ -10,160 +10,206 @@
* limitations under the License.
*/
-import { expect, test } from '@playwright/test';
+import { expect, Page, Request, test } from '@playwright/test';
import { LoginPage } from '../../models/login-page';
import { LoginTestUtil, TestCredentials } from '../../models/login-page.util';
import { addPageAnnotationBeforeEach, PAGES } from '../../utils';
-test.describe('Login Page', () => {
- test.use({ storageState: { cookies: [], origins: [] } });
+// Login requests contain credentials and tickets, including in retry traces.
+test.use({
+ storageState: { cookies: [], origins: [] },
+ trace: 'off',
+ video: 'off',
+ screenshot: 'off',
+ serviceWorkers: 'block'
+});
+const destination = '/credential?probe=returnurl';
+const loginUrl = `/#/login?returnUrl=${encodeURIComponent(destination)}`;
+const isLogin = (request: Request) => request.method() === 'POST' && new
URL(request.url()).pathname === '/api/login';
+
+const expectUnauthenticated = async (page: Page) => {
+ const response = await page.context().request.get('/api/security/ticket', {
maxRedirects: 0 });
+ // Do not follow Shiro's redirect into the login endpoint.
+ expect(response.status()).toBe(302);
+ expect(new URL(response.headers().location,
response.url()).pathname).toBe('/api/login');
+};
+
+const expectAuthenticated = async (page: Page, username: string) => {
+ const cookies = await page.context().cookies(page.url());
+ expect(cookies.some(cookie => cookie.name === 'JSESSIONID' && cookie.value
!== '' && cookie.httpOnly)).toBe(true);
+ // A session cookie alone can also belong to an unauthenticated session.
+ const response = await page.context().request.get('/api/security/ticket', {
maxRedirects: 0 });
+ expect(response.status()).toBe(200);
+ expect((await response.json()).body.principal).toBe(username);
+};
+
+const submitSuccessfully = async (page: Page, loginPage: LoginPage,
credentials: TestCredentials) => {
+ const responsePromise = page.waitForResponse(response =>
isLogin(response.request()));
+ await loginPage.login(credentials.username, credentials.password);
+ const response = await responsePromise;
+ expect(response.status()).toBe(200);
+ expect((await response.json()).body.principal).toBe(credentials.username);
+};
+
+const expectHome = async (page: Page, loginPage: LoginPage, username: string)
=> {
+ await expect(page).toHaveURL(url => url.hash === '#/');
+ // The login page also has a welcome heading; scope this to the destination.
+ await expect(page.locator('zeppelin-home').getByRole('heading', { name:
'Welcome to Zeppelin!' })).toBeVisible();
+ await expect(loginPage.formContainer).toBeHidden();
+ await expectAuthenticated(page, username);
+};
+
+const expectUsableFailure = async (page: Page, loginPage: LoginPage) => {
+ await expect(loginPage.errorMessage).toBeVisible();
+ await expect(page).toHaveURL(url => url.hash === '#/login');
+ await expect(loginPage.formContainer).toBeVisible();
+ // nzLoading suppresses clicks without setting the native disabled attribute.
+ await expect(loginPage.loginButton).not.toHaveClass(/ant-btn-loading/);
+ await expect(loginPage.loginButton).toBeEnabled();
+ await expect(loginPage.userNameInput).toBeEditable();
+ await expect(loginPage.passwordInput).toBeEditable();
+ await expectUnauthenticated(page);
+};
+
+test.describe('Login route handoff', () => {
+ // Keep submissions for this account ordered even when the runner is fully
parallel.
+ test.describe.configure({ mode: 'default' });
addPageAnnotationBeforeEach(PAGES.PAGES.LOGIN);
+ let credentials: TestCredentials;
let loginPage: LoginPage;
- let testCredentials: Record<string, TestCredentials>;
test.beforeAll(async ({ request }) => {
- const isShiroEnabled = await LoginTestUtil.isShiroEnabled();
- if (!isShiroEnabled) {
- test.skip(true, 'Skipping all login tests - shiro.ini not found');
+ test.skip(!(await LoginTestUtil.isShiroEnabled()), 'Login scenarios
require Shiro authentication');
+ const response = await request.get('/api/security/ticket', { maxRedirects:
0 });
+ if (response.ok()) {
+ const ticket = await response.json();
+ test.skip(ticket.body.principal === 'anonymous', 'Login scenarios do not
run against an anonymous server');
}
-
- const ticketResponse = await request.get('/api/security/ticket', {
failOnStatusCode: false });
- if (ticketResponse.ok()) {
- const ticket = await ticketResponse.json();
- if (ticket?.body?.principal === 'anonymous') {
- test.skip(true, 'Skipping all login tests - Zeppelin server is running
in anonymous mode');
- }
+ const account = Object.values(await
LoginTestUtil.getTestCredentials()).find(value =>
+ value.roles?.includes('admin')
+ );
+ if (!account) {
+ throw new Error('Login handoff requires an admin account for the
credential destination');
}
-
- testCredentials = await LoginTestUtil.getTestCredentials();
+ credentials = account;
});
test.beforeEach(async ({ page }) => {
+ await expectUnauthenticated(page);
loginPage = new LoginPage(page);
- await loginPage.navigate();
});
- test('should display login form with required elements', async () => {
+ test('submits valid credentials and establishes a browser session at home',
async ({ page }) => {
+ await loginPage.navigate();
await expect(loginPage.formContainer).toBeVisible();
- await expect(loginPage.userNameInput).toBeVisible();
- await expect(loginPage.passwordInput).toBeVisible();
- await expect(loginPage.loginButton).toBeVisible();
- await expect(loginPage.welcomeTitle).toBeVisible();
- });
-
- test('should have proper input field attributes', async () => {
- await expect(loginPage.userNameInput).toHaveAttribute('placeholder', 'User
Name');
- await expect(loginPage.passwordInput).toHaveAttribute('placeholder',
'Password');
- await expect(loginPage.passwordInput).toHaveAttribute('type', 'password');
- await expect(loginPage.userNameInput).toHaveAttribute('name', 'userName');
- await expect(loginPage.passwordInput).toHaveAttribute('name', 'password');
- });
-
- test('should allow text input in form fields', async () => {
- const testUsername = 'testuser';
- const testPassword = 'testpass';
-
- await loginPage.userNameInput.fill(testUsername);
- await loginPage.passwordInput.fill(testPassword);
-
- await expect(loginPage.userNameInput).toHaveValue(testUsername);
- await expect(loginPage.passwordInput).toHaveValue(testPassword);
+ await submitSuccessfully(page, loginPage, credentials);
+ await expectHome(page, loginPage, credentials.username);
});
- test('should display error message for invalid credentials', async () => {
- const invalidCreds = testCredentials.INVALID_USER;
-
- await loginPage.login(invalidCreds.username, invalidCreds.password);
- await loginPage.waitForErrorMessage();
-
- const errorText = await loginPage.getErrorMessageText();
- expect(errorText).toContain("The username and password that you entered
don't match");
- });
-
- test('should login successfully with valid credentials', async ({ page }) =>
{
- const validUserKey = Object.keys(testCredentials).find(
- key => key !== 'INVALID_USER' && key !== 'EMPTY_CREDENTIALS'
- );
- const validCreds = testCredentials[validUserKey || 'user1'];
-
- // Attempt to login with valid credentials
- await loginPage.login(validCreds.username, validCreds.password);
-
- // Wait for navigation and verify the new URL
- await page.waitForURL('/#/');
- await expect(page).toHaveURL('/#/');
-
- // Verify the login form is no longer visible
- await expect(loginPage.formContainer).not.toBeVisible();
+ test('supports keyboard navigation through the login form', async ({ page })
=> {
+ await loginPage.navigate();
+ await loginPage.userNameInput.focus();
+ await expect(loginPage.userNameInput).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(loginPage.passwordInput).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(loginPage.loginButton).toBeFocused();
});
for (const entry of ['login', 'protected route']) {
- test(`should preserve returnUrl through a cold ${entry} entry and
successful authentication`, async ({ page }) => {
- const destination = '/credential?probe=returnurl';
- const credentials = Object.values(testCredentials).find(account =>
account.roles?.includes('admin'));
- if (!credentials) {
- throw new Error('The returnUrl regression requires an admin account
for the credential destination');
- }
- const entryUrl = entry === 'login' ?
`/#/login?returnUrl=${encodeURIComponent(destination)}` : `/#${destination}`;
-
- // Force a new document, rather than updating query params on an already
mounted login component.
- await page.goto('about:blank');
+ test(`preserves returnUrl through a cold ${entry} entry and successful
authentication`, async ({ page }) => {
+ const entryUrl = entry === 'login' ? loginUrl : `/#${destination}`;
+ // The fresh page fixture has not mounted the login component before
this entry.
await page.goto(entryUrl);
- await expect(loginPage.userNameInput).toBeVisible();
+ await expect(loginPage.formContainer).toBeVisible();
await expect(page).toHaveURL(url => {
const [path, query] = url.hash.split('?');
return path === '#/login' && new
URLSearchParams(query).get('returnUrl') === destination;
});
- const responsePromise = page.waitForResponse(
- response => response.request().method() === 'POST' && new
URL(response.url()).pathname === '/api/login'
- );
- await loginPage.login(credentials.username, credentials.password);
- const response = await responsePromise;
- expect(response.status()).toBe(200);
- expect((await
response.json()).body.principal).toBe(credentials.username);
+ await submitSuccessfully(page, loginPage, credentials);
await expect(page).toHaveURL(url => url.hash === `#${destination}`);
await expect(
page.getByText('Manage your credentials. You can add new credential
information.', { exact: true })
).toBeVisible();
- await expect(loginPage.userNameInput).toBeHidden();
-
- const cookies = await page.context().cookies(page.url());
- expect(cookies.some(cookie => cookie.name === 'JSESSIONID' &&
cookie.value !== '' && cookie.httpOnly)).toBe(true);
- const ticket = await page.context().request.get('/api/security/ticket',
{ maxRedirects: 0 });
- expect(ticket.status()).toBe(200);
- expect((await ticket.json()).body.principal).toBe(credentials.username);
+ await expect(loginPage.formContainer).toBeHidden();
+ await expectAuthenticated(page, credentials.username);
});
}
- test('should maintain form state after failed login', async () => {
- const invalidCreds = testCredentials.INVALID_USER;
- await loginPage.login(invalidCreds.username, invalidCreds.password);
- await loginPage.waitForErrorMessage();
+ test('redirects an authenticated login visit to home without submitting
credentials again', async ({ page }) => {
+ await loginPage.navigate();
+ await submitSuccessfully(page, loginPage, credentials);
+ await expectHome(page, loginPage, credentials.username);
- await expect(loginPage.userNameInput).toHaveValue(invalidCreds.username);
- await expect(loginPage.passwordInput).toHaveValue(invalidCreds.password);
+ const submissions: Request[] = [];
+ const observe = (request: Request) => {
+ if (isLogin(request)) {
+ submissions.push(request);
+ }
+ };
+ page.on('request', observe);
+ try {
+ // LoginGuard sends authenticated visitors home even when returnUrl is
present.
+ await page.goto(loginUrl);
+ await expectHome(page, loginPage, credentials.username);
+ expect(submissions).toHaveLength(0);
+ } finally {
+ page.off('request', observe);
+ }
});
- test('should support keyboard navigation', async ({ page }) => {
- await loginPage.userNameInput.focus();
- await expect(loginPage.userNameInput).toBeFocused();
-
- await page.keyboard.press('Tab');
- await expect(loginPage.passwordInput).toBeFocused();
-
- await page.keyboard.press('Tab');
- await expect(loginPage.loginButton).toBeFocused();
+ test('rejects invalid credentials without leaving the form loading and
accepts a corrected retry', async ({
+ page
+ }) => {
+ await loginPage.navigate();
+ const responsePromise = page.waitForResponse(response =>
isLogin(response.request()));
+ await loginPage.login(credentials.username,
`${credentials.password}-incorrect`);
+ expect((await responsePromise).status()).toBe(403);
+ await expectUsableFailure(page, loginPage);
+ await submitSuccessfully(page, loginPage, credentials);
+ await expectHome(page, loginPage, credentials.username);
});
- test('should handle form submission with Enter key', async ({ page }) => {
- const testCreds = testCredentials.INVALID_USER;
- await loginPage.login(testCreds.username, testCreds.password);
-
- await loginPage.passwordInput.focus();
- await page.keyboard.press('Enter');
-
- await loginPage.waitForErrorMessage();
- });
+ for (const failure of ['network failure', 'HTTP 503']) {
+ test(`recovers from ticket submission ${failure} and authenticates on
retry`, async ({ page }) => {
+ await loginPage.navigate();
+ await expect(loginPage.formContainer).toBeVisible();
+ let intercepted = 0;
+ await page.route('**/api/login', async route => {
+ if (!isLogin(route.request())) {
+ await route.fallback();
+ return;
+ }
+ intercepted++;
+ if (failure === 'network failure') {
+ await route.abort('failed');
+ } else {
+ await route.fulfill({
+ status: 503,
+ contentType: 'application/json',
+ body: '{"status":"SERVICE_UNAVAILABLE"}'
+ });
+ }
+ });
+ try {
+ const resultPromise =
+ failure === 'network failure'
+ ? page.waitForEvent('requestfailed', { predicate: isLogin
}).then(() => 'network failure')
+ : page
+ .waitForResponse(response => isLogin(response.request()))
+ .then(response => `HTTP ${response.status()}`);
+ await loginPage.login(credentials.username, credentials.password);
+ expect(await resultPromise).toBe(failure);
+ expect(intercepted).toBe(1);
+ await expectUsableFailure(page, loginPage);
+ } finally {
+ await page.unroute('**/api/login');
+ }
+ // Only the failed attempt is intercepted; the retry must authenticate
with Shiro.
+ await submitSuccessfully(page, loginPage, credentials);
+ await expectHome(page, loginPage, credentials.username);
+ });
+ }
});