feat(auth): implement authentication service, guards, and interceptors

- Add AuthService for handling login, registration, and session management.
- Create auth guards to protect routes based on authentication status.
- Implement auth interceptor to attach JWT token to HTTP requests and handle 401 errors.
- Add unit tests for AuthService, guards, and interceptor.
- Create login and registration components with form validation and error handling.
- Update routing to include guards for login and registration pages.
This commit is contained in:
2026-07-02 16:37:06 -03:00
parent 57f58c92be
commit 6ffffc6ea8
17 changed files with 1206 additions and 31 deletions

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export function authBootstrap(): Promise<void> {
const authService = inject(AuthService);
return authService.bootstrap();
}

View File

@@ -0,0 +1,62 @@
import { computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { AuthService } from './auth.service';
import { authGuard, guestOnlyGuard } from './auth.guards';
function createAuthServiceStub(isAuthenticated = false) {
const tokenState = signal<string | null>(isAuthenticated ? 'jwt-token' : null);
return {
isAuthenticated: computed(() => tokenState() !== null)
};
}
describe('auth guards', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('redirects unauthenticated users to /login', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
});
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/login');
});
it('allows authenticated users through authGuard', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() => authGuard(null as never, null as never));
expect(result).toBe(true);
});
it('redirects authenticated users away from guest routes', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub(true) }]
});
const result = TestBed.runInInjectionContext(() => guestOnlyGuard(null as never, null as never));
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
});
it('allows guests into login/register routes', () => {
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: AuthService, useValue: createAuthServiceStub() }]
});
const result = TestBed.runInInjectionContext(() => guestOnlyGuard(null as never, null as never));
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
export const guestOnlyGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isAuthenticated() ? router.createUrlTree(['/']) : true;
};

View File

@@ -0,0 +1,96 @@
import { HttpClient } 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';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
import { authInterceptor } from './auth.interceptor';
function createAuthServiceStub(token: string | null = null) {
const tokenState = signal(token);
return {
token: tokenState.asReadonly(),
isAuthenticated: computed(() => tokenState() !== null),
clearSession: vi.fn(() => tokenState.set(null))
};
}
describe('authInterceptor', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('adds the bearer token when a session exists', () => {
const authService = createAuthServiceStub('jwt-token');
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService }
]
});
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
client.get(`${environment.url}me`).subscribe();
const request = httpController.expectOne(`${environment.url}me`);
expect(request.request.headers.get('Authorization')).toBe('Bearer jwt-token');
request.flush({ id: 1 });
httpController.verify();
});
it('does not add the authorization header when no token exists', () => {
const authService = createAuthServiceStub();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService }
]
});
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
client.get(`${environment.url}productos`).subscribe();
const request = httpController.expectOne(`${environment.url}productos`);
expect(request.request.headers.has('Authorization')).toBe(false);
request.flush([]);
httpController.verify();
});
it('clears the local session when an authenticated request returns 401', () => {
const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authService }
]
});
const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController);
client.get(`${environment.url}me`).subscribe({
error: () => undefined
});
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(authService.clearSession).toHaveBeenCalledTimes(1);
httpController.verify();
});
});

View File

@@ -0,0 +1,37 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const authService = inject(AuthService);
const token = authService.token();
const authenticatedRequest = token
? request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
})
: request;
return next(authenticatedRequest).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && token) {
authService.clearSession();
}
return throwError(() => error);
})
);
};

View File

@@ -0,0 +1,29 @@
export interface AuthUser {
id: number;
nombre_apellido: string;
email: string;
}
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload {
nombre_apellido: string;
email: string;
password: string;
password_confirmation: string;
}
export interface LoginResponse {
message: string;
token: string;
token_type: 'Bearer';
user: AuthUser;
}
export interface RegisterResponse {
message: string;
data: AuthUser;
}

View File

@@ -0,0 +1,188 @@
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
describe('AuthService', () => {
beforeEach(() => {
window.localStorage.clear();
TestBed.resetTestingModule();
});
it('stores token and user on successful login', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.login({ email: 'ada@example.com', password: 'secret123' }).subscribe((user) => {
expect(user.email).toBe('ada@example.com');
});
const request = httpController.expectOne(`${environment.url}login`);
expect(request.request.method).toBe('POST');
request.flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(service.isAuthenticated()).toBe(true);
expect(window.localStorage.getItem('shopit.auth.token')).toBe('plain-text-token');
httpController.verify();
});
it('hydrates token from localStorage and loads the current user during bootstrap', async () => {
window.localStorage.setItem('shopit.auth.token', 'persisted-token');
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}me`);
expect(request.request.method).toBe('GET');
request.flush({
id: 9,
nombre_apellido: 'Grace Hopper',
email: 'grace@example.com'
});
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.user()?.email).toBe('grace@example.com');
expect(service.isAuthenticated()).toBe(true);
httpController.verify();
});
it('clears the session when bootstrap receives 401 from /me', async () => {
window.localStorage.setItem('shopit.auth.token', 'expired-token');
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(window.localStorage.getItem('shopit.auth.token')).toBeNull();
httpController.verify();
});
it('registers without creating a session', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service
.register({
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com',
password: 'secret123',
password_confirmation: 'secret123'
})
.subscribe((response) => {
expect(response.data.email).toBe('ada@example.com');
});
const request = httpController.expectOne(`${environment.url}register`);
expect(request.request.method).toBe('POST');
request.flush({
message: 'Usuario registrado correctamente.',
data: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
httpController.verify();
});
it('clears local session even when logout request fails', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
service.login({ email: 'ada@example.com', password: 'secret123' }).subscribe();
httpController.expectOne(`${environment.url}login`).flush({
message: 'Sesion iniciada correctamente.',
token: 'plain-text-token',
token_type: 'Bearer',
user: {
id: 1,
nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com'
}
});
service.logout().subscribe({
error: () => undefined
});
const logoutRequest = httpController.expectOne(`${environment.url}logout`);
expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(window.localStorage.getItem('shopit.auth.token')).toBeNull();
httpController.verify();
});
it('does not access localStorage on the server', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: PLATFORM_ID, useValue: 'server' }
]
});
const service = TestBed.inject(AuthService);
service.hydrateSession();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
});

View File

@@ -0,0 +1,115 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment';
import {
AuthUser,
LoginPayload,
LoginResponse,
RegisterPayload,
RegisterResponse
} from './auth.interfaces';
const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
readonly user = this.userState.asReadonly();
readonly token = this.tokenState.asReadonly();
readonly isAuthenticated = computed(() => this.tokenState() !== null);
login(payload: LoginPayload): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}login`, payload).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
register(payload: RegisterPayload): Observable<RegisterResponse> {
return this.http.post<RegisterResponse>(`${environment.url}register`, payload);
}
logout(): Observable<void> {
if (!this.tokenState()) {
this.clearSession();
return of(void 0);
}
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({
next: () => this.clearSession(),
error: () => this.clearSession()
})
);
}
async bootstrap(): Promise<void> {
this.hydrateSession();
if (!this.tokenState() || this.userState()) {
return;
}
try {
await firstValueFrom(this.loadCurrentUser());
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
return;
}
throw error;
}
}
loadCurrentUser(): Observable<AuthUser> {
return this.http.get<AuthUser>(`${environment.url}me`).pipe(
tap((user) => this.userState.set(user))
);
}
hydrateSession(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
this.tokenState.set(token);
}
clearSession(): void {
this.userState.set(null);
this.tokenState.set(null);
if (!isPlatformBrowser(this.platformId)) {
return;
}
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
}
private applyAuthenticatedState(token: string, user: AuthUser): void {
this.tokenState.set(token);
this.userState.set(user);
if (!isPlatformBrowser(this.platformId)) {
return;
}
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
}