This is an automated email from the ASF dual-hosted git repository.
chanholee 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 5484bebb6e [ZEPPELIN-6339] Fix - Anonymous users can access /login in
the New UI, unlike the Classic UI
5484bebb6e is described below
commit 5484bebb6e988d474787147d725f7db17d619d91
Author: YONGJAE LEE(이용재) <[email protected]>
AuthorDate: Fri Oct 3 23:46:01 2025 +0900
[ZEPPELIN-6339] Fix - Anonymous users can access /login in the New UI,
unlike the Classic UI
### What is this PR for?
#### [Classic UI]
https://github.com/user-attachments/assets/9ae01649-5525-4310-b467-4dc96f205987
#### [As-Is]
https://github.com/user-attachments/assets/654ce13a-1770-4c61-bc4c-c9cc09ba397d
#### [To-Be]
https://github.com/user-attachments/assets/9c8140e3-ff7f-4698-af93-8ae6859f6dee
Anonymous users were able to access /login in the New UI, unlike the
Classic UI.
This has been restricted, and I have added tests to cover the change.
### What type of PR is it?
Bug Fix
### Todos
### What is the Jira issue?
ZEPPELIN-6339
### How should this be tested?
### Screenshots (if appropriate)
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5087 from dididy/fix/login-issue.
Signed-off-by: ChanHo Lee <[email protected]>
---
zeppelin-web-angular/e2e/models/home-page.ts | 116 +++++++++++++++
zeppelin-web-angular/e2e/models/home-page.util.ts | 129 ++++++++++++++++
.../anonymous-login-redirect.spec.ts | 164 +++++++++++++++++++++
zeppelin-web-angular/e2e/utils.ts | 23 ++-
.../src/app/pages/login/login-routing.module.ts | 4 +-
.../src/app/pages/login/login.guard.ts | 35 +++++
.../src/app/services/ticket.service.ts | 4 +
7 files changed, 473 insertions(+), 2 deletions(-)
diff --git a/zeppelin-web-angular/e2e/models/home-page.ts
b/zeppelin-web-angular/e2e/models/home-page.ts
new file mode 100644
index 0000000000..7d24fdf3ed
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/home-page.ts
@@ -0,0 +1,116 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Locator, Page, expect } from '@playwright/test';
+import { BasePage } from './base-page';
+import { getCurrentPath, waitForUrlNotContaining } from '../utils';
+
+export class HomePage extends BasePage {
+ readonly welcomeHeading: Locator;
+ readonly notebookSection: Locator;
+ readonly helpSection: Locator;
+ readonly communitySection: Locator;
+ readonly createNewNoteButton: Locator;
+ readonly importNoteButton: Locator;
+ readonly searchInput: Locator;
+ readonly filterInput: Locator;
+ readonly zeppelinLogo: Locator;
+ readonly anonymousUserIndicator: Locator;
+ readonly tutorialNotebooks: {
+ flinkTutorial: Locator;
+ pythonTutorial: Locator;
+ sparkTutorial: Locator;
+ rTutorial: Locator;
+ miscellaneousTutorial: Locator;
+ };
+ readonly externalLinks: {
+ documentation: Locator;
+ mailingList: Locator;
+ issuesTracking: Locator;
+ github: Locator;
+ };
+
+ constructor(page: Page) {
+ super(page);
+ this.welcomeHeading = page.locator('h1', { hasText: 'Welcome to Zeppelin!'
});
+ this.notebookSection = page.locator('text=Notebook').first();
+ this.helpSection = page.locator('text=Help').first();
+ this.communitySection = page.locator('text=Community').first();
+ this.createNewNoteButton = page.locator('text=Create new Note');
+ this.importNoteButton = page.locator('text=Import Note');
+ this.searchInput = page.locator('textbox', { hasText: 'Search' });
+ this.filterInput = page.locator('input[placeholder*="Filter"]');
+ this.zeppelinLogo = page.locator('text=Zeppelin').first();
+ this.anonymousUserIndicator = page.locator('text=anonymous');
+
+ this.tutorialNotebooks = {
+ flinkTutorial: page.locator('text=Flink Tutorial'),
+ pythonTutorial: page.locator('text=Python Tutorial'),
+ sparkTutorial: page.locator('text=Spark Tutorial'),
+ rTutorial: page.locator('text=R Tutorial'),
+ miscellaneousTutorial: page.locator('text=Miscellaneous Tutorial')
+ };
+
+ this.externalLinks = {
+ documentation: page.locator('a[href*="zeppelin.apache.org/docs"]'),
+ mailingList: page.locator('a[href*="community.html"]'),
+ issuesTracking: page.locator('a[href*="issues.apache.org"]'),
+ github: page.locator('a[href*="github.com/apache/zeppelin"]')
+ };
+ }
+
+ async navigateToHome(): Promise<void> {
+ await this.page.goto('/', { waitUntil: 'load' });
+ await this.waitForPageLoad();
+ }
+
+ async navigateToLogin(): Promise<void> {
+ await this.page.goto('/#/login', { waitUntil: 'load' });
+ await this.waitForPageLoad();
+ // Wait for potential redirect to complete by checking URL change
+ await waitForUrlNotContaining(this.page, '#/login');
+ }
+
+ async isHomeContentDisplayed(): Promise<boolean> {
+ try {
+ await expect(this.welcomeHeading).toBeVisible();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async isAnonymousUser(): Promise<boolean> {
+ try {
+ await expect(this.anonymousUserIndicator).toBeVisible();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async clickZeppelinLogo(): Promise<void> {
+ await this.zeppelinLogo.click();
+ }
+
+ async getCurrentURL(): Promise<string> {
+ return this.page.url();
+ }
+
+ getCurrentPath(): string {
+ return getCurrentPath(this.page);
+ }
+
+ async getPageTitle(): Promise<string> {
+ return this.page.title();
+ }
+}
diff --git a/zeppelin-web-angular/e2e/models/home-page.util.ts
b/zeppelin-web-angular/e2e/models/home-page.util.ts
new file mode 100644
index 0000000000..4211a722c0
--- /dev/null
+++ b/zeppelin-web-angular/e2e/models/home-page.util.ts
@@ -0,0 +1,129 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Page, expect } from '@playwright/test';
+import { HomePage } from './home-page';
+import { getBasicPageMetadata, waitForUrlNotContaining } from '../utils';
+
+export class HomePageUtil {
+ private homePage: HomePage;
+ private page: Page;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.homePage = new HomePage(page);
+ }
+
+ async verifyAnonymousUserRedirectFromLogin(): Promise<{
+ isLoginUrlMaintained: boolean;
+ isHomeContentDisplayed: boolean;
+ isAnonymousUser: boolean;
+ currentPath: string;
+ }> {
+ await this.homePage.navigateToLogin();
+
+ const currentPath = this.homePage.getCurrentPath();
+ const isLoginUrlMaintained = currentPath.includes('#/login');
+ const isHomeContentDisplayed = await
this.homePage.isHomeContentDisplayed();
+ const isAnonymousUser = await this.homePage.isAnonymousUser();
+
+ return {
+ isLoginUrlMaintained,
+ isHomeContentDisplayed,
+ isAnonymousUser,
+ currentPath
+ };
+ }
+
+ async verifyHomePageIntegrity(): Promise<void> {
+ await this.verifyHomePageElements();
+ await this.verifyNotebookFunctionalities();
+ await this.verifyTutorialNotebooks();
+ await this.verifyExternalLinks();
+ }
+
+ async verifyHomePageElements(): Promise<void> {
+ await expect(this.homePage.welcomeHeading).toBeVisible();
+ await expect(this.homePage.notebookSection).toBeVisible();
+ await expect(this.homePage.helpSection).toBeVisible();
+ await expect(this.homePage.communitySection).toBeVisible();
+ }
+
+ async verifyNotebookFunctionalities(): Promise<void> {
+ await expect(this.homePage.createNewNoteButton).toBeVisible();
+ await expect(this.homePage.importNoteButton).toBeVisible();
+
+ const filterInputCount = await this.homePage.filterInput.count();
+ if (filterInputCount > 0) {
+ await expect(this.homePage.filterInput).toBeVisible();
+ }
+ }
+
+ async verifyTutorialNotebooks(): Promise<void> {
+ await expect(this.homePage.tutorialNotebooks.flinkTutorial).toBeVisible();
+ await expect(this.homePage.tutorialNotebooks.pythonTutorial).toBeVisible();
+ await expect(this.homePage.tutorialNotebooks.sparkTutorial).toBeVisible();
+ await expect(this.homePage.tutorialNotebooks.rTutorial).toBeVisible();
+ await
expect(this.homePage.tutorialNotebooks.miscellaneousTutorial).toBeVisible();
+ }
+
+ async verifyExternalLinks(): Promise<void> {
+ const docCount = await this.homePage.externalLinks.documentation.count();
+ const mailCount = await this.homePage.externalLinks.mailingList.count();
+ const issuesCount = await
this.homePage.externalLinks.issuesTracking.count();
+ const githubCount = await this.homePage.externalLinks.github.count();
+
+ if (docCount > 0) await
expect(this.homePage.externalLinks.documentation).toBeVisible();
+ if (mailCount > 0) await
expect(this.homePage.externalLinks.mailingList).toBeVisible();
+ if (issuesCount > 0) await
expect(this.homePage.externalLinks.issuesTracking).toBeVisible();
+ if (githubCount > 0) await
expect(this.homePage.externalLinks.github).toBeVisible();
+ }
+
+ async testNavigationConsistency(): Promise<{
+ pathBeforeClick: string;
+ pathAfterClick: string;
+ homeContentMaintained: boolean;
+ }> {
+ const pathBeforeClick = this.homePage.getCurrentPath();
+
+ await this.homePage.clickZeppelinLogo();
+ await this.homePage.waitForPageLoad();
+
+ const pathAfterClick = this.homePage.getCurrentPath();
+ const homeContentMaintained = await this.homePage.isHomeContentDisplayed();
+
+ return {
+ pathBeforeClick,
+ pathAfterClick,
+ homeContentMaintained
+ };
+ }
+
+ async getPageMetadata(): Promise<{
+ title: string;
+ path: string;
+ isAnonymous: boolean;
+ }> {
+ const basicMetadata = await getBasicPageMetadata(this.page);
+ const isAnonymous = await this.homePage.isAnonymousUser();
+
+ return {
+ ...basicMetadata,
+ isAnonymous
+ };
+ }
+
+ async navigateToLoginAndWaitForRedirect(): Promise<void> {
+ await this.page.goto('/#/login', { waitUntil: 'load' });
+ await waitForUrlNotContaining(this.page, '#/login');
+ }
+}
diff --git
a/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts
b/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts
new file mode 100644
index 0000000000..34b9f498f0
--- /dev/null
+++
b/zeppelin-web-angular/e2e/tests/authentication/anonymous-login-redirect.spec.ts
@@ -0,0 +1,164 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, test } from '@playwright/test';
+import { ZeppelinHelper } from '../../helper';
+import { HomePageUtil } from '../../models/home-page.util';
+import { addPageAnnotationBeforeEach, PAGES, waitForUrlNotContaining,
getCurrentPath } from '../../utils';
+
+test.describe('Anonymous User Login Redirect', () => {
+ addPageAnnotationBeforeEach(PAGES.WORKSPACE.HOME);
+
+ let zeppelinHelper: ZeppelinHelper;
+ let homePageUtil: HomePageUtil;
+
+ test.beforeEach(async ({ page }) => {
+ zeppelinHelper = new ZeppelinHelper(page);
+ homePageUtil = new HomePageUtil(page);
+ });
+
+ test.describe('Given an anonymous user is already logged in', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ });
+
+ test('When accessing login page directly, Then should redirect to home
with proper URL change', async ({
+ page
+ }) => {
+ const redirectResult = await
homePageUtil.verifyAnonymousUserRedirectFromLogin();
+
+ expect(redirectResult.isLoginUrlMaintained).toBe(false);
+ expect(redirectResult.isHomeContentDisplayed).toBe(true);
+ expect(redirectResult.isAnonymousUser).toBe(true);
+ expect(redirectResult.currentPath).toContain('#/');
+ expect(redirectResult.currentPath).not.toContain('#/login');
+ });
+
+ test('When accessing login page directly, Then should display all home
page elements correctly', async ({
+ page
+ }) => {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ await homePageUtil.verifyHomePageIntegrity();
+ });
+
+ test('When clicking Zeppelin logo after redirect, Then should maintain
home URL and content', async ({ page }) => {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ const navigationResult = await homePageUtil.testNavigationConsistency();
+
+ expect(navigationResult.pathBeforeClick).toContain('#/');
+ expect(navigationResult.pathBeforeClick).not.toContain('#/login');
+ expect(navigationResult.pathAfterClick).toContain('#/');
+ expect(navigationResult.homeContentMaintained).toBe(true);
+ });
+
+ test('When accessing login page, Then should redirect and maintain
anonymous user state', async ({ page }) => {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ const metadata = await homePageUtil.getPageMetadata();
+
+ expect(metadata.title).toContain('Zeppelin');
+ expect(metadata.path).toContain('#/');
+ expect(metadata.path).not.toContain('#/login');
+ expect(metadata.isAnonymous).toBe(true);
+ });
+
+ test('When accessing login page, Then should display welcome heading and
main sections', async ({ page }) => {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ await expect(page.locator('h1', { hasText: 'Welcome to Zeppelin!'
})).toBeVisible();
+ await expect(page.locator('text=Notebook').first()).toBeVisible();
+ await expect(page.locator('text=Help').first()).toBeVisible();
+ await expect(page.locator('text=Community').first()).toBeVisible();
+ });
+
+ test('When accessing login page, Then should display notebook
functionalities', async ({ page }) => {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ await expect(page.locator('text=Create new Note')).toBeVisible();
+ await expect(page.locator('text=Import Note')).toBeVisible();
+
+ const filterInput = page.locator('input[placeholder*="Filter"]');
+ if ((await filterInput.count()) > 0) {
+ await expect(filterInput).toBeVisible();
+ }
+ });
+
+ test('When accessing login page, Then should display external links in
help and community sections', async ({
+ page
+ }) => {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ const docLinks = page.locator('a[href*="zeppelin.apache.org/docs"]');
+ const communityLinks = page.locator('a[href*="community.html"]');
+ const issuesLinks = page.locator('a[href*="issues.apache.org"]');
+ const githubLinks =
page.locator('a[href*="github.com/apache/zeppelin"]');
+
+ if ((await docLinks.count()) > 0) await expect(docLinks).toBeVisible();
+ if ((await communityLinks.count()) > 0) await
expect(communityLinks).toBeVisible();
+ if ((await issuesLinks.count()) > 0) await
expect(issuesLinks).toBeVisible();
+ if ((await githubLinks.count()) > 0) await
expect(githubLinks).toBeVisible();
+ });
+
+ test('When navigating between home and login URLs, Then should maintain
consistent user experience', async ({
+ page
+ }) => {
+ await page.goto('/', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+
+ const homeMetadata = await homePageUtil.getPageMetadata();
+ expect(homeMetadata.path).toContain('#/');
+ expect(homeMetadata.isAnonymous).toBe(true);
+
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await page.waitForURL(url => !url.toString().includes('#/login'));
+
+ const loginMetadata = await homePageUtil.getPageMetadata();
+ expect(loginMetadata.path).toContain('#/');
+ expect(loginMetadata.path).not.toContain('#/login');
+ expect(loginMetadata.isAnonymous).toBe(true);
+
+ const isHomeContentDisplayed = await
homePageUtil.verifyAnonymousUserRedirectFromLogin();
+ expect(isHomeContentDisplayed.isHomeContentDisplayed).toBe(true);
+ });
+
+ test('When multiple page loads occur on login URL, Then should
consistently redirect to home', async ({ page }) => {
+ for (let i = 0; i < 3; i++) {
+ await page.goto('/#/login', { waitUntil: 'load' });
+ await zeppelinHelper.waitForZeppelinReady();
+ await waitForUrlNotContaining(page, '#/login');
+
+ await expect(page.locator('h1', { hasText: 'Welcome to Zeppelin!'
})).toBeVisible();
+ await expect(page.locator('text=anonymous')).toBeVisible();
+
+ const path = getCurrentPath(page);
+ expect(path).toContain('#/');
+ expect(path).not.toContain('#/login');
+ }
+ });
+ });
+});
diff --git a/zeppelin-web-angular/e2e/utils.ts
b/zeppelin-web-angular/e2e/utils.ts
index 50f1be17c7..c5ceec8442 100644
--- a/zeppelin-web-angular/e2e/utils.ts
+++ b/zeppelin-web-angular/e2e/utils.ts
@@ -10,7 +10,7 @@
* limitations under the License.
*/
-import { test, TestInfo } from '@playwright/test';
+import { test, TestInfo, Page } from '@playwright/test';
export const PAGES = {
// Main App
@@ -137,3 +137,24 @@ export function flattenPageComponents(pages:
PageStructureType): string[] {
export function getCoverageTransformPaths(): string[] {
return flattenPageComponents(PAGES);
}
+
+export async function waitForUrlNotContaining(page: Page, fragment: string) {
+ await page.waitForURL(url => !url.toString().includes(fragment));
+}
+
+export function getCurrentPath(page: Page): string {
+ const url = new URL(page.url());
+ return url.hash || url.pathname;
+}
+
+export async function getBasicPageMetadata(
+ page: Page
+): Promise<{
+ title: string;
+ path: string;
+}> {
+ return {
+ title: await page.title(),
+ path: getCurrentPath(page)
+ };
+}
diff --git a/zeppelin-web-angular/src/app/pages/login/login-routing.module.ts
b/zeppelin-web-angular/src/app/pages/login/login-routing.module.ts
index 06dd0304db..d85802d19c 100644
--- a/zeppelin-web-angular/src/app/pages/login/login-routing.module.ts
+++ b/zeppelin-web-angular/src/app/pages/login/login-routing.module.ts
@@ -14,11 +14,13 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginComponent } from './login.component';
+import { LoginGuard } from './login.guard';
const routes: Routes = [
{
path: '',
- component: LoginComponent
+ component: LoginComponent,
+ canActivate: [LoginGuard]
}
];
diff --git a/zeppelin-web-angular/src/app/pages/login/login.guard.ts
b/zeppelin-web-angular/src/app/pages/login/login.guard.ts
new file mode 100644
index 0000000000..c52e702cfe
--- /dev/null
+++ b/zeppelin-web-angular/src/app/pages/login/login.guard.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { CanActivate, Router, UrlTree } from '@angular/router';
+import { of, Observable } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+
+import { TicketService } from '@zeppelin/services';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LoginGuard implements CanActivate {
+ constructor(private ticketService: TicketService, private router: Router) {}
+
+ canActivate(): Observable<boolean> {
+ return this.ticketService.isAuthenticated().pipe(
+ map(() => {
+ this.router.navigate(['/'], { replaceUrl: true });
+ return false;
+ }),
+ catchError(() => of(true))
+ );
+ }
+}
diff --git a/zeppelin-web-angular/src/app/services/ticket.service.ts
b/zeppelin-web-angular/src/app/services/ticket.service.ts
index d12da61120..868b36c225 100644
--- a/zeppelin-web-angular/src/app/services/ticket.service.ts
+++ b/zeppelin-web-angular/src/app/services/ticket.service.ts
@@ -65,6 +65,10 @@ export class TicketService {
this.ticket$.next(this.ticket);
}
+ isAuthenticated() {
+ return this.getTicket();
+ }
+
clearTicket() {
this.ticket = new ITicketWrapped();
this.originTicket = new ITicket();