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);
+    });
+  }
 });

Reply via email to