feat(auth): update session handling on 401 responses and refactor error propagation

This commit is contained in:
2026-07-21 10:36:48 -03:00
parent 8e44fa5a46
commit da6614a9f8
5 changed files with 30 additions and 56 deletions

View File

@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core'; import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
httpController.verify(); httpController.verify();
}); });
it('clears the local session when an authenticated request returns 401', () => { it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token'); const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient); const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({ client.get(`${environment.url}me`).subscribe({
error: () => undefined error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
}); });
const request = httpController.expectOne(`${environment.url}me`); const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(authService.clearSession).toHaveBeenCalledTimes(1); expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify(); httpController.verify();
}); });
}); });

View File

@@ -1,12 +1,6 @@
import { import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest
} from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -25,13 +19,5 @@ export const authInterceptor: HttpInterceptorFn = (
}) })
: request; : request;
return next(authenticatedRequest).pipe( return next(authenticatedRequest);
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && token) {
authService.clearSession();
}
return throwError(() => error);
})
);
}; };

View File

@@ -95,7 +95,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears the session when bootstrap receives 401 from /me', async () => { it('propagates a 401 from /me without clearing the session', async () => {
cookieStore['shopit.auth.token'] = 'expired-token'; cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -116,11 +116,11 @@ describe('AuthService', () => {
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
await expect(bootstrapPromise).resolves.toBeUndefined(); await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 });
expect(service.user()).toBeNull(); expect(service.user()).toBeNull();
expect(service.token()).toBeNull(); expect(service.token()).toBe('expired-token');
expect(service.isAuthenticated()).toBe(false); expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBeUndefined(); expect(cookieStore['shopit.auth.token']).toBe('expired-token');
httpController.verify(); httpController.verify();
}); });
@@ -168,7 +168,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears local session even when logout request fails', () => { it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
@@ -202,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST'); expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBeNull(); expect(service.token()).toBe('plain-text-token');
expect(service.user()).toBeNull(); expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBeUndefined(); expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify(); httpController.verify();
}); });

View File

@@ -1,4 +1,4 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -57,10 +57,7 @@ export class AuthService {
} }
return this.http.post<void>(`${environment.url}logout`, {}).pipe( return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({ tap(() => this.clearSession())
next: () => this.clearSession(),
error: () => this.clearSession()
})
); );
} }
@@ -78,19 +75,10 @@ export class AuthService {
return; return;
} }
try {
const user = await firstValueFrom(this.loadCurrentUser()); const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
} }
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
return;
}
throw error;
}
} }
loadCurrentUser(): Observable<AuthUser> { loadCurrentUser(): Observable<AuthUser> {
@@ -115,8 +103,4 @@ export class AuthService {
this.userState.set(user); this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token); this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
} }
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
} }

View File

@@ -23,12 +23,9 @@ export class CookieService {
return null; return null;
} }
set(name: string, value: string, days = 7): void { set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
const date = new Date(); this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
} }
} }