feat(auth): migrate sessionStorage to localStorage and add refreshSession()
- Replace sessionStorage with localStorage for user data persistence - Add refreshSession() function to restore session from HTTPOnly cookie via /users/me - Update tests to use localStorage mocks - Add 3 new tests for refreshSession() functionality - Update superadmin credentials in README.md and DEVOPS.md
This commit is contained in:
parent
1f9aacf81b
commit
31fadc1b11
4 changed files with 122 additions and 24 deletions
18
README.md
18
README.md
|
|
@ -236,12 +236,20 @@ cd seeder-api && npm install && npm run seed
|
||||||
|
|
||||||
## 🔑 Credenciais de Teste
|
## 🔑 Credenciais de Teste
|
||||||
|
|
||||||
| Tipo | Login | Senha | Acesso |
|
> [!NOTE]
|
||||||
|
> O SuperAdmin foi atualizado via migration `032_update_superadmin_lol.sql`.
|
||||||
|
> No primeiro login será necessário trocar a senha (status `force_change_password`).
|
||||||
|
|
||||||
|
| Tipo | Login | Email | Acesso |
|
||||||
|------|-------|-------|--------|
|
|------|-------|-------|--------|
|
||||||
| **SuperAdmin** | `superadmin` | `Admin@2025!` | Full |
|
| **SuperAdmin** | `lol` | `lol@gohorsejobs.com` | Full |
|
||||||
| **Company Admin** | `takeshi_yamamoto` | `Takeshi@2025` | Empresa |
|
| **Company Admin** | `takeshi_yamamoto` | - | Empresa |
|
||||||
| **Recruiter** | `maria_santos` | `User@2025` | Vagas |
|
| **Recruiter** | `maria_santos` | - | Vagas |
|
||||||
| **Candidate** | `paulo_santos` | `User@2025` | Candidato |
|
| **Candidate** | `paulo_santos` | - | Candidato |
|
||||||
|
|
||||||
|
**Senhas padrão:**
|
||||||
|
- SuperAdmin: *trocar no primeiro acesso*
|
||||||
|
- Demais usuários: `User@2025` ou `Takeshi@2025`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,9 +228,13 @@ npm run seed
|
||||||
|
|
||||||
## 👤 Test Users
|
## 👤 Test Users
|
||||||
|
|
||||||
|
> **Nota:** O SuperAdmin foi atualizado via migration `032_update_superadmin_lol.sql`.
|
||||||
|
|
||||||
### SuperAdmin
|
### SuperAdmin
|
||||||
- **Login:** `superadmin`
|
- **Login:** `lol`
|
||||||
- **Password:** `Admin@2025!`
|
- **Email:** `lol@gohorsejobs.com`
|
||||||
|
- **Nome:** Dr. Horse Expert
|
||||||
|
- **Password:** *trocar no primeiro acesso* (status `force_change_password`)
|
||||||
|
|
||||||
### Company Admin
|
### Company Admin
|
||||||
| Login | Password |
|
| Login | Password |
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { RegisterCandidateData } from '../auth';
|
||||||
const mockFetch = jest.fn();
|
const mockFetch = jest.fn();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
// Mock sessionStorage
|
// Mock localStorage
|
||||||
const sessionStorageMock = {
|
const localStorageMock = {
|
||||||
getItem: jest.fn(),
|
getItem: jest.fn(),
|
||||||
setItem: jest.fn(),
|
setItem: jest.fn(),
|
||||||
removeItem: jest.fn(),
|
removeItem: jest.fn(),
|
||||||
clear: jest.fn(),
|
clear: jest.fn(),
|
||||||
};
|
};
|
||||||
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
|
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||||
|
|
||||||
// Mock config module to avoid initConfig fetching
|
// Mock config module to avoid initConfig fetching
|
||||||
jest.mock('../config', () => ({
|
jest.mock('../config', () => ({
|
||||||
|
|
@ -26,9 +26,9 @@ describe('Auth Module', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
sessionStorageMock.getItem.mockReset();
|
localStorageMock.getItem.mockReset();
|
||||||
sessionStorageMock.setItem.mockReset();
|
localStorageMock.setItem.mockReset();
|
||||||
sessionStorageMock.removeItem.mockReset();
|
localStorageMock.removeItem.mockReset();
|
||||||
|
|
||||||
// Re-import the module fresh
|
// Re-import the module fresh
|
||||||
authModule = require('../auth');
|
authModule = require('../auth');
|
||||||
|
|
@ -149,12 +149,12 @@ describe('Auth Module', () => {
|
||||||
|
|
||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
expect(user?.email).toBe('test@example.com');
|
expect(user?.email).toBe('test@example.com');
|
||||||
expect(sessionStorageMock.setItem).toHaveBeenCalledWith(
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
'job-portal-auth',
|
'job-portal-auth',
|
||||||
expect.any(String)
|
expect.any(String)
|
||||||
);
|
);
|
||||||
// Token is in cookie, not in storage
|
// Token is in cookie, not in storage
|
||||||
expect(sessionStorageMock.setItem).not.toHaveBeenCalledWith(
|
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
|
||||||
'auth_token',
|
'auth_token',
|
||||||
expect.any(String)
|
expect.any(String)
|
||||||
);
|
);
|
||||||
|
|
@ -176,15 +176,51 @@ describe('Auth Module', () => {
|
||||||
it('should remove auth data from localStorage', () => {
|
it('should remove auth data from localStorage', () => {
|
||||||
authModule.logout();
|
authModule.logout();
|
||||||
|
|
||||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth');
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth');
|
||||||
// expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshSession', () => {
|
||||||
|
it('should restore user from /users/me endpoint', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
id: '123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
roles: ['candidate'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await authModule.refreshSession();
|
||||||
|
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.email).toBe('test@example.com');
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear storage on 401', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
|
||||||
|
|
||||||
|
const user = await authModule.refreshSession();
|
||||||
|
|
||||||
|
expect(user).toBeNull();
|
||||||
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null on network error', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const user = await authModule.refreshSession();
|
||||||
|
|
||||||
|
expect(user).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCurrentUser', () => {
|
describe('getCurrentUser', () => {
|
||||||
it('should return user from localStorage', () => {
|
it('should return user from localStorage', () => {
|
||||||
const storedUser = { id: '123', name: 'Test', email: 'test@test.com', role: 'candidate' };
|
const storedUser = { id: '123', name: 'Test', email: 'test@test.com', role: 'candidate' };
|
||||||
sessionStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedUser));
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedUser));
|
||||||
|
|
||||||
const user = authModule.getCurrentUser();
|
const user = authModule.getCurrentUser();
|
||||||
|
|
||||||
|
|
@ -192,7 +228,7 @@ describe('Auth Module', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null when no user stored', () => {
|
it('should return null when no user stored', () => {
|
||||||
sessionStorageMock.getItem.mockReturnValueOnce(null);
|
localStorageMock.getItem.mockReturnValueOnce(null);
|
||||||
|
|
||||||
const user = authModule.getCurrentUser();
|
const user = authModule.getCurrentUser();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export async function login(
|
||||||
|
|
||||||
// Store user info in sessionStorage (not token - token is in httpOnly cookie)
|
// Store user info in sessionStorage (not token - token is in httpOnly cookie)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
sessionStorage.setItem(AUTH_KEY, JSON.stringify(user));
|
localStorage.setItem(AUTH_KEY, JSON.stringify(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|
@ -78,7 +78,7 @@ export async function login(
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
sessionStorage.removeItem(AUTH_KEY);
|
localStorage.removeItem(AUTH_KEY);
|
||||||
// Call backend to clear the httpOnly cookie
|
// Call backend to clear the httpOnly cookie
|
||||||
try {
|
try {
|
||||||
await fetch(`${getApiV1Url()}/auth/logout`, {
|
await fetch(`${getApiV1Url()}/auth/logout`, {
|
||||||
|
|
@ -93,17 +93,67 @@ export async function logout(): Promise<void> {
|
||||||
|
|
||||||
export function getCurrentUser(): User | null {
|
export function getCurrentUser(): User | null {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const stored = sessionStorage.getItem(AUTH_KEY);
|
const stored = localStorage.getItem(AUTH_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const user = JSON.parse(stored);
|
const user = JSON.parse(stored);
|
||||||
console.log("%c[AUTH] User Loaded from Session", "color: #10b981", user.email);
|
console.log("%c[AUTH] User Loaded from Storage", "color: #10b981", user.email);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.warn("%c[AUTH] No user found in session", "color: #f59e0b");
|
console.warn("%c[AUTH] No user found in storage", "color: #f59e0b");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to map backend roles to frontend role
|
||||||
|
function mapRoleFromBackend(roles: string[]): "candidate" | "admin" | "company" | "superadmin" {
|
||||||
|
if (roles.includes("superadmin") || roles.includes("SUPERADMIN")) {
|
||||||
|
return "superadmin";
|
||||||
|
}
|
||||||
|
if (roles.includes("admin") || roles.includes("recruiter")) {
|
||||||
|
return "company";
|
||||||
|
}
|
||||||
|
return "candidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the session by calling /users/me with the HTTPOnly cookie.
|
||||||
|
* Use this on app mount to restore session across tabs/reloads.
|
||||||
|
*/
|
||||||
|
export async function refreshSession(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
console.log("%c[AUTH] Attempting to refresh session...", "color: #3b82f6");
|
||||||
|
const res = await fetch(`${getApiV1Url()}/users/me`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include", // Send HTTPOnly cookie
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// Cookie expired or invalid - clear local storage
|
||||||
|
console.warn("%c[AUTH] Session refresh failed:", "color: #f59e0b", res.status);
|
||||||
|
localStorage.removeItem(AUTH_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await res.json();
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
id: userData.id,
|
||||||
|
name: userData.name || userData.full_name,
|
||||||
|
email: userData.email,
|
||||||
|
role: mapRoleFromBackend(userData.roles || []),
|
||||||
|
roles: userData.roles || [],
|
||||||
|
profileComplete: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(AUTH_KEY, JSON.stringify(user));
|
||||||
|
console.log("%c[AUTH] Session restored from cookie", "color: #10b981", user.email);
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("%c[AUTH] Failed to refresh session:", "color: #ef4444", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isAdminUser(user: User | null): boolean {
|
export function isAdminUser(user: User | null): boolean {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
const roles = user.roles || [];
|
const roles = user.roles || [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue