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 00000000000..7d24fdf3ed9 --- /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 { + await this.page.goto('/', { waitUntil: 'load' }); + await this.waitForPageLoad(); + } + + async navigateToLogin(): Promise { + 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 { + try { + await expect(this.welcomeHeading).toBeVisible(); + return true; + } catch { + return false; + } + } + + async isAnonymousUser(): Promise { + try { + await expect(this.anonymousUserIndicator).toBeVisible(); + return true; + } catch { + return false; + } + } + + async clickZeppelinLogo(): Promise { + await this.zeppelinLogo.click(); + } + + async getCurrentURL(): Promise { + return this.page.url(); + } + + getCurrentPath(): string { + return getCurrentPath(this.page); + } + + async getPageTitle(): Promise { + 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 00000000000..4211a722c06 --- /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 { + await this.verifyHomePageElements(); + await this.verifyNotebookFunctionalities(); + await this.verifyTutorialNotebooks(); + await this.verifyExternalLinks(); + } + + async verifyHomePageElements(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 00000000000..34b9f498f05 --- /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 50f1be17c76..c5ceec8442e 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 06dd0304db8..d85802d19cf 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 00000000000..c52e702cfed --- /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 { + 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 d12da61120f..868b36c225f 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();