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 { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
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');
TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({
error: () => undefined
error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
});
const request = httpController.expectOne(`${environment.url}me`);
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();
});
});

View File

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

View File

@@ -95,7 +95,7 @@ describe('AuthService', () => {
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';
TestBed.configureTestingModule({
@@ -116,11 +116,11 @@ describe('AuthService', () => {
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.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(cookieStore['shopit.auth.token']).toBeUndefined();
expect(service.token()).toBe('expired-token');
expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBe('expired-token');
httpController.verify();
});
@@ -168,7 +168,7 @@ describe('AuthService', () => {
httpController.verify();
});
it('clears local session even when logout request fails', () => {
it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
@@ -202,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
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 { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -57,10 +57,7 @@ export class AuthService {
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({
next: () => this.clearSession(),
error: () => this.clearSession()
})
tap(() => this.clearSession())
);
}
@@ -78,18 +75,9 @@ export class AuthService {
return;
}
try {
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
return;
}
throw error;
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
}
@@ -115,8 +103,4 @@ export class AuthService {
this.userState.set(user);
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;
}
set(name: string, value: string, days = 7): void {
set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
}
}