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 006a3e1578 [ZEPPELIN-6643] Log out on session expiry instead of 
throwing in the interceptor
006a3e1578 is described below

commit 006a3e157860a4ba18cd2210ac7bb7ff8a082e7f
Author: 김예나 <[email protected]>
AuthorDate: Thu Sep 10 20:26:53 2026 +0900

    [ZEPPELIN-6643] Log out on session expiry instead of throwing in the 
interceptor
    
    ### What is this PR for?
    
    The New UI never logs out on session expiry. `AppHttpInterceptor` guards 
its 405 branch with `event.url.contains('logout')`, and JavaScript strings have 
no `contains` method, so the guard throws a `TypeError` inside `catchError` 
before `ticketService.logout()` is reached. Both statements after it are 
skipped: logout never runs, and the caller observes a `TypeError` instead of 
the 405 it needs to act on. The expired session stays in place until the user 
reloads the page by hand.
    
    The guard's intent is right and is kept. It exists so that a 405 on the 
logout request itself does not call logout again, which would recurse. Only the 
method name changes:
    
    ```diff
    -} else if (event.status === 405 && !event.url.contains('logout')) {
    +} else if (event.status === 405 && !event.url?.includes('logout')) {
    ```
    
    `includes` is the method that exists. The optional chain covers 
`HttpErrorResponse.url` being `null`, which it is whenever the failure carries 
no resolved url, and which would otherwise throw at the same spot for a 
different reason. A 405 with no url cannot be identified as the logout call, so 
it falls through to logout — the same conclusion the branch already draws for 
every other request, and the safe one when the session is likely gone.
    
    The 401 redirect branch is untouched.
    
    Out of scope, and left alone deliberately: the substring match means a 405 
on a path that merely contains `logout` is also skipped, and the `catchError` 
parameter is untyped. Both belong to 
[ZEPPELIN-6469](https://issues.apache.org/jira/browse/ZEPPELIN-6469), which 
waits on this behaviour being correct first.
    
    The spec constructs the interceptor directly with a `logout` stub rather 
than starting `TestBed`, per `zeppelin-web-angular/AGENTS.md`: no Angular 
wiring is under test here, only the branch. It pins three things the branch has 
to get right — a non-logout 405 calls logout exactly once, that 405 reaches the 
caller unchanged rather than replaced by a `TypeError`, and a 405 from the 
logout request itself does not call logout again — plus the null-url path.
    
    ### What type of PR is it?
    
    Bug Fix
    
    ### Todos
    
    None
    
    ### What is the Jira issue?
    
    * https://issues.apache.org/jira/browse/ZEPPELIN-6643
    
    ### How should this be tested?
    
    * `npm run test:shell` — 53 tests across 12 files, green. The four new ones 
are in `src/app/app-http.interceptor.spec.ts`.
    * The assertions were checked by breaking what they cover. Reverting the 
source line to `event.url.contains('logout')` fails three of the four, with 
`AssertionError: expected TypeError: event.url.contains is not a function to be 
HttpErrorResponse`. The fourth — the recursion guard — passes either way, 
because the `TypeError` also happens to prevent the logout call; it is there to 
confirm the guard survives the fix, not to reproduce the bug.
    * `npx prettier --check` on both files, clean. `npx eslint` on both reports 
only the two `prefer-arrow/prefer-arrow-functions` warnings that the named test 
helpers produce, the same two `src/app/services/save-as.service.spec.ts` 
already reports on master.
    * Not run locally: Playwright, and the production builds. Neither is 
reachable from this change — it is one expression in an interceptor plus a unit 
spec — but saying so rather than implying otherwise.
    
    Manual reproduction, for a reviewer who wants to see the original failure: 
with an expired session, any REST call from the New UI answers 405 and the 
browser console shows `event.url.contains is not a function` from the 
interceptor, with no logout request following it. After this change the same 
405 is followed by `POST /api/login/logout`.
    
    ### Screenshots (if appropriate)
    
    Not applicable.
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No. The 405 branch did 
nothing but throw before this change, so nothing could have depended on it.
    * Does this needs documentation? No
    
    
    Closes #5464 from kimyenac/ZEPPELIN-6643.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../src/app/app-http.interceptor.spec.ts           | 160 +++++++++++++++++++++
 .../src/app/app-http.interceptor.ts                |  20 ++-
 2 files changed, 176 insertions(+), 4 deletions(-)

diff --git a/zeppelin-web-angular/src/app/app-http.interceptor.spec.ts 
b/zeppelin-web-angular/src/app/app-http.interceptor.spec.ts
new file mode 100644
index 0000000000..95922beac0
--- /dev/null
+++ b/zeppelin-web-angular/src/app/app-http.interceptor.spec.ts
@@ -0,0 +1,160 @@
+/*
+ * 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 { HttpClient, HttpErrorResponse, HttpEvent, HttpHandler, HttpRequest } 
from '@angular/common/http';
+import { Router } from '@angular/router';
+import { defer, firstValueFrom, of, Subject, throwError } from 'rxjs';
+
+import { NzMessageService } from 'ng-zorro-antd/message';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { BaseUrlService, TicketService } from '@zeppelin/services';
+
+import { AppHttpInterceptor } from './app-http.interceptor';
+
+const REST_BASE = 'http://localhost:8080/api';
+
+/**
+ * The server answers an expired session with 405 on the REST base rather than 
401, so the 405
+ * branch is the only path that reaches logout. The response carries no 
Location header, which
+ * is what keeps the 401 branch out of the way.
+ */
+function sessionExpired(url: string | undefined): HttpErrorResponse {
+  return new HttpErrorResponse({ status: 405, url });
+}
+
+describe('AppHttpInterceptor', () => {
+  let logout: ReturnType<typeof vi.fn>;
+  let logoutSubscribed: ReturnType<typeof vi.fn<() => void>>;
+  let interceptor: AppHttpInterceptor;
+
+  /** Drives one request through the interceptor and returns whatever the 
caller would observe. */
+  function intercept(failure: HttpErrorResponse, url = REST_BASE, method = 
'GET'): Promise<unknown> {
+    const next: HttpHandler = { handle: () => throwError(() => failure) };
+    return firstValueFrom(interceptor.intercept(new HttpRequest(method, url, 
null), next));
+  }
+
+  beforeEach(() => {
+    logoutSubscribed = vi.fn<() => void>();
+    logout = vi.fn(() =>
+      defer(() => {
+        logoutSubscribed();
+        return of({});
+      })
+    );
+    interceptor = new AppHttpInterceptor({ logout } as unknown as 
TicketService);
+  });
+
+  it('logs out once when a non-logout request is answered with 405', async () 
=> {
+    const failure = sessionExpired(`${REST_BASE}/notebook`);
+
+    await expect(intercept(failure, 
`${REST_BASE}/notebook`)).rejects.toBe(failure);
+
+    // `String.prototype.contains` does not exist, so this branch used to 
throw before reaching logout
+    expect(logout).toHaveBeenCalledTimes(1);
+    expect(logoutSubscribed).toHaveBeenCalledTimes(1);
+  });
+
+  it('rethrows the 405 it logged out on instead of a TypeError', async () => {
+    const failure = sessionExpired(`${REST_BASE}/notebook`);
+
+    await expect(intercept(failure, 
`${REST_BASE}/notebook`)).rejects.toBe(failure);
+  });
+
+  it.each([
+    ['the logout URL', `${REST_BASE}/login/logout`],
+    ['the redirected login URL', `${REST_BASE}/login`],
+    ['no URL', undefined]
+  ])('does not retry logout when its 405 response reports %s', async 
(_description, responseUrl) => {
+    const failure = sessionExpired(responseUrl);
+
+    await expect(intercept(failure, `${REST_BASE}/login/logout`, 
'POST')).rejects.toBe(failure);
+
+    expect(logout).not.toHaveBeenCalled();
+    expect(logoutSubscribed).not.toHaveBeenCalled();
+  });
+
+  it('logs out on a non-logout request whose 405 reports no url', async () => {
+    const failure = sessionExpired(undefined);
+
+    await expect(intercept(failure, 
`${REST_BASE}/notebook`)).rejects.toBe(failure);
+
+    expect(logout).toHaveBeenCalledTimes(1);
+    expect(logoutSubscribed).toHaveBeenCalledTimes(1);
+  });
+
+  it('finishes ticket cleanup without another request when logout redirects to 
a login 405', async () => {
+    const requests: Array<{ url: string; response: Subject<HttpEvent<unknown>> 
}> = [];
+    const backend: HttpHandler = {
+      handle: request => {
+        const response = new Subject<HttpEvent<unknown>>();
+        requests.push({ url: request.url, response });
+        return response;
+      }
+    };
+    const client = new HttpClient({ handle: request => 
interceptor.intercept(request, backend) });
+    const navigate = vi.fn(() => Promise.resolve(true));
+    const service = new TicketService(
+      client,
+      { getRestApiBase: () => REST_BASE } as BaseUrlService,
+      { navigate } as unknown as Router,
+      { success: vi.fn() } as unknown as NzMessageService
+    );
+    service.ticket.init = true;
+    service.ticket.principal = 'user1';
+    interceptor = new AppHttpInterceptor(service);
+    const failure = sessionExpired(`${REST_BASE}/login`);
+    const result = firstValueFrom(client.get(`${REST_BASE}/notebook`));
+
+    requests[0].response.error(failure);
+    await expect(result).rejects.toBe(failure);
+    expect(service.logout$.value).toBe(true);
+    expect(requests.map(request => 
request.url)).toEqual([`${REST_BASE}/notebook`, `${REST_BASE}/login/logout`]);
+
+    requests[1].response.error(sessionExpired(`${REST_BASE}/login`));
+
+    expect(requests.map(request => 
request.url)).toEqual([`${REST_BASE}/notebook`, `${REST_BASE}/login/logout`]);
+    expect(service.ticket.init).toBe(false);
+    expect(service.ticket.principal).toBe('');
+    expect(service.logout$.value).toBe(false);
+    expect(navigate).toHaveBeenCalledExactlyOnceWith(['/login']);
+  });
+
+  it.each(['complete', 'error'])('deduplicates pending logout and allows 
another after %s', async outcome => {
+    const pendingLogout = new Subject<object>();
+    logout.mockImplementationOnce(() =>
+      defer(() => {
+        logoutSubscribed();
+        return pendingLogout;
+      })
+    );
+    const firstFailure = sessionExpired(`${REST_BASE}/notebook`);
+    const secondFailure = sessionExpired(`${REST_BASE}/interpreter`);
+
+    await expect(intercept(firstFailure, 
`${REST_BASE}/notebook`)).rejects.toBe(firstFailure);
+    await expect(intercept(secondFailure, 
`${REST_BASE}/interpreter`)).rejects.toBe(secondFailure);
+
+    expect(logout).toHaveBeenCalledTimes(1);
+    expect(logoutSubscribed).toHaveBeenCalledTimes(1);
+
+    if (outcome === 'error') {
+      pendingLogout.error(sessionExpired(`${REST_BASE}/login`));
+    } else {
+      pendingLogout.complete();
+    }
+
+    await expect(intercept(firstFailure, 
`${REST_BASE}/notebook`)).rejects.toBe(firstFailure);
+    expect(logout).toHaveBeenCalledTimes(2);
+    expect(logoutSubscribed).toHaveBeenCalledTimes(2);
+  });
+});
diff --git a/zeppelin-web-angular/src/app/app-http.interceptor.ts 
b/zeppelin-web-angular/src/app/app-http.interceptor.ts
index 6a6a4a1853..c6a80d9f2c 100644
--- a/zeppelin-web-angular/src/app/app-http.interceptor.ts
+++ b/zeppelin-web-angular/src/app/app-http.interceptor.ts
@@ -12,8 +12,8 @@
 
 import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } 
from '@angular/common/http';
 import { Injectable } from '@angular/core';
-import { throwError, Observable } from 'rxjs';
-import { catchError, map } from 'rxjs/operators';
+import { EMPTY, throwError, Observable } from 'rxjs';
+import { catchError, finalize, map } from 'rxjs/operators';
 
 import { isNil } from 'lodash';
 
@@ -22,6 +22,8 @@ import { TicketService } from '@zeppelin/services';
 
 @Injectable()
 export class AppHttpInterceptor implements HttpInterceptor {
+  private logoutInProgress = false;
+
   constructor(private ticketService: TicketService) {}
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -48,8 +50,18 @@ export class AppHttpInterceptor implements HttpInterceptor {
         if (event.status === 401 && !isNil(redirect)) {
           // Handle page redirect
           window.location.href = redirect;
-        } else if (event.status === 405 && !event.url.contains('logout')) {
-          this.ticketService.logout().subscribe();
+        } else if (event.status === 405 && !httpRequest.url.includes('logout') 
&& !this.logoutInProgress) {
+          this.logoutInProgress = true;
+          this.ticketService
+            .logout()
+            .pipe(
+              // TicketService clears the ticket and navigates even when the 
logout request fails.
+              catchError(() => EMPTY),
+              finalize(() => {
+                this.logoutInProgress = false;
+              })
+            )
+            .subscribe();
         }
         return throwError(event);
       })

Reply via email to