Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions frontend/src/__tests__/auth/client-bypass.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ describe('client-side auth bypass attempts (adversarial, must hold)', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
window.sessionStorage.clear();
unauthenticatedSession();
});

it('a forged localStorage sysadmin profile grants no session without cookies', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify({
window.sessionStorage.setItem('regengine_user', JSON.stringify({
id: 'attacker',
email: 'attacker@example.com',
is_sysadmin: true,
Expand All @@ -92,7 +93,7 @@ describe('client-side auth bypass attempts (adversarial, must hold)', () => {
});

it('a forged member profile cannot flip isAuthenticated after expiry', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify({
window.sessionStorage.setItem('regengine_user', JSON.stringify({
id: 'attacker',
email: 'attacker@example.com',
is_sysadmin: false,
Expand All @@ -111,7 +112,8 @@ describe('client-side auth bypass attempts (adversarial, must hold)', () => {
it('XSS-shaped email text seeded client-side stays inert data, not markup', async () => {
const { seedPasswordlessClientUser } = await import('@/lib/passwordless-auth');
seedPasswordlessClientUser('<img src=x onerror=alert(1)>@example.com');
const raw = localStorageMock.getItem('regengine_user');
const raw = window.sessionStorage.getItem('regengine_user');
expect(localStorageMock.getItem('regengine_user')).toBeNull();
expect(raw).toContain('<img src=x onerror=alert(1)>');
const parsed = JSON.parse(raw as string) as {
email: string; is_sysadmin: boolean; permissions: string[];
Expand Down
80 changes: 63 additions & 17 deletions frontend/src/__tests__/hooks/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('useAuth Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
window.sessionStorage.clear();
(isSupabaseConfigured as any).mockReturnValue(false);
(createSupabaseBrowserClient as any).mockImplementation(() => {
throw new Error('Supabase not configured');
Expand Down Expand Up @@ -116,7 +117,7 @@ describe('useAuth Hook', () => {
updated_at: '2026-01-27T00:00:00Z',
};

localStorageMock.setItem('regengine_user', JSON.stringify(mockUser));
window.sessionStorage.setItem('regengine_user', JSON.stringify(mockUser));

// Session endpoint says user is authenticated (cookie has token)
mockFetch.mockImplementation((url: string, options?: RequestInit) => {
Expand Down Expand Up @@ -184,7 +185,7 @@ describe('useAuth Hook', () => {
expect(result.current.accessToken).toBe('cookie-managed');
});

it('persists user to localStorage on login', async () => {
it('persists a minimal user profile to sessionStorage on login', async () => {
const mockUser = {
id: '123',
email: 'test@example.com',
Expand All @@ -204,8 +205,15 @@ describe('useAuth Hook', () => {
await result.current.login('test-token', mockUser, 'tenant-123');
});

// User profile is still stored in localStorage (non-sensitive)
expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(mockUser));
// User profile is cached in sessionStorage with only the fields the UI reads
expect(JSON.parse(window.sessionStorage.getItem('regengine_user') as string)).toEqual({
id: '123',
email: 'test@example.com',
is_sysadmin: false,
status: 'active',
role_id: 'role-1',
});
expect(localStorageMock.getItem('regengine_user')).toBeNull();
// Tenant ID is still stored in localStorage (non-sensitive)
expect(localStorageMock.getItem('regengine_tenant_id')).toBe('tenant-123');
});
Expand Down Expand Up @@ -414,7 +422,7 @@ describe('useAuth Hook', () => {
status: 'active',
permissions: ['inflow.write', 'reports.read'],
};
localStorageMock.setItem('regengine_user', JSON.stringify(storedUser));
window.sessionStorage.setItem('regengine_user', JSON.stringify(storedUser));

mockFetch.mockImplementation((url: string, options?: RequestInit) => {
if (typeof url === 'string' && url.includes('/api/session')) {
Expand Down Expand Up @@ -442,7 +450,14 @@ describe('useAuth Hook', () => {
});

expect(result.current.user?.permissions).toEqual(['inflow.write', 'reports.read']);
expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(supabaseUser));
expect(JSON.parse(window.sessionStorage.getItem('regengine_user') as string)).toEqual({
id: 'supabase-user',
email: 'stored@example.com',
is_sysadmin: false,
status: 'active',
role_name: 'member',
permissions: ['inflow.write', 'reports.read'],
});
expect(createSupabaseBrowserClient).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -577,7 +592,7 @@ describe('useAuth Hook', () => {
await result.current.logout();
});

expect(localStorageMock.getItem('regengine_user')).toBeNull();
expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
expect(localStorageMock.getItem('regengine_tenant_id')).toBeNull();
});

Expand Down Expand Up @@ -815,7 +830,7 @@ describe('useAuth Hook', () => {
};

it('clears the localStorage-hydrated user when the session reports unauthenticated', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify(staleUser));
window.sessionStorage.setItem('regengine_user', JSON.stringify(staleUser));
// Default beforeEach mock: GET /api/session → authenticated: false

const { result } = renderHook(() => useAuth(), { wrapper });
Expand All @@ -826,12 +841,12 @@ describe('useAuth Hook', () => {

expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.getItem('regengine_user')).toBeNull();
expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
expect(apiClient.setUser).toHaveBeenLastCalledWith(null);
});

it('does not re-persist a session user echoed back from a stale re_user cookie', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify(staleUser));
window.sessionStorage.setItem('regengine_user', JSON.stringify(staleUser));

// re_access_token expired but the non-httpOnly re_user cookie
// survived — the route echoes it back with authenticated: false.
Expand Down Expand Up @@ -861,11 +876,11 @@ describe('useAuth Hook', () => {
});

expect(result.current.user).toBeNull();
expect(localStorageMock.getItem('regengine_user')).toBeNull();
expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
});

it('keeps the hydrated user when the session check fails with a network error', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify(staleUser));
window.sessionStorage.setItem('regengine_user', JSON.stringify(staleUser));

mockFetch.mockImplementation((url: string, options?: RequestInit) => {
if (typeof url === 'string' && url.includes('/api/session') &&
Expand All @@ -882,11 +897,11 @@ describe('useAuth Hook', () => {
});

expect(result.current.user?.id).toBe('stale-user');
expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(staleUser));
expect(window.sessionStorage.getItem('regengine_user')).toBe(JSON.stringify(staleUser));
});

it('keeps the hydrated user when the session check returns a non-2xx response', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify(staleUser));
window.sessionStorage.setItem('regengine_user', JSON.stringify(staleUser));

mockFetch.mockImplementation((url: string, options?: RequestInit) => {
if (typeof url === 'string' && url.includes('/api/session') &&
Expand All @@ -903,7 +918,7 @@ describe('useAuth Hook', () => {
});

expect(result.current.user?.id).toBe('stale-user');
expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(staleUser));
expect(window.sessionStorage.getItem('regengine_user')).toBe(JSON.stringify(staleUser));
});

it('does not wipe a login that completed while the hydrate session check was in flight', async () => {
Expand Down Expand Up @@ -968,15 +983,15 @@ describe('useAuth Hook', () => {
});

expect(result.current.user?.id).toBe('fresh-user');
expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(mockUser));
expect(window.sessionStorage.getItem('regengine_user')).toBe(JSON.stringify(mockUser));

document.cookie = 're_csrf=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
});

describe('Error Handling', () => {
it('handles corrupted localStorage data gracefully', async () => {
localStorageMock.setItem('regengine_user', 'invalid-json');
window.sessionStorage.setItem('regengine_user', 'invalid-json');

const { result } = renderHook(() => useAuth(), { wrapper });

Expand All @@ -995,4 +1010,35 @@ describe('useAuth Hook', () => {
expect(result.current.user).toBeNull();
});
});

describe('Session profile storage (code-scanning alert 69)', () => {
it('removes a legacy localStorage profile on load', async () => {
localStorageMock.setItem('regengine_user', JSON.stringify({ id: 'legacy', email: 'legacy@regengine.co' }));

const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.isHydrated).toBe(true));

expect(localStorageMock.getItem('regengine_user')).toBeNull();
});

it('clears the sessionStorage profile on logout', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await act(async () => {
await result.current.login('test-token', {
id: 'logout-user',
email: 'logout@regengine.co',
is_sysadmin: false,
status: 'active',
});
});
expect(window.sessionStorage.getItem('regengine_user')).not.toBeNull();

await act(async () => {
await result.current.logout();
});

expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
expect(localStorageMock.getItem('regengine_user')).toBeNull();
});
});
});
102 changes: 102 additions & 0 deletions frontend/src/__tests__/lib/client-session-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
CLIENT_SESSION_USER_KEY,
clearClientSessionUser,
purgeLegacyClientSessionUser,
readClientSessionUser,
seedClientSessionUser,
toClientSessionProfile,
writeClientSessionUser,
} from '@/lib/client-session-profile';
import type { User } from '@/types/api';

describe('client-session-profile', () => {
beforeEach(() => {
window.sessionStorage.clear();
window.localStorage.clear();
});

it('seeds the profile into sessionStorage, never localStorage', () => {
seedClientSessionUser('seed@regengine.co');

expect(window.localStorage.getItem(CLIENT_SESSION_USER_KEY)).toBeNull();
expect(readClientSessionUser()).toEqual({
id: 'email-session',
email: 'seed@regengine.co',
is_sysadmin: false,
status: 'active',
role_name: 'member',
permissions: [],
});
});

it('removes a legacy localStorage copy when seeding', () => {
window.localStorage.setItem(CLIENT_SESSION_USER_KEY, '{"id":"old","email":"old@regengine.co"}');
seedClientSessionUser('seed@regengine.co');
expect(window.localStorage.getItem(CLIENT_SESSION_USER_KEY)).toBeNull();
});

it('stores only the fields the client needs', () => {
const user = {
id: crypto.randomUUID(),
email: 'member@regengine.co',
is_sysadmin: false,
status: 'active',
role_name: 'member',
permissions: ['reports.read'],
created_at: '2026-10-26T00:00:00Z',
name: 'Extra Field',
tenant_id: crypto.randomUUID(),
} as unknown as User;

writeClientSessionUser(user);
const stored = JSON.parse(window.sessionStorage.getItem(CLIENT_SESSION_USER_KEY) as string);

expect(Object.keys(stored).sort()).toEqual(
['email', 'id', 'is_sysadmin', 'permissions', 'role_name', 'status'],
);
expect(toClientSessionProfile(user)).toEqual(stored);
});

it('clears both the session copy and any legacy localStorage copy', () => {
seedClientSessionUser('seed@regengine.co');
window.localStorage.setItem(CLIENT_SESSION_USER_KEY, '{}');

clearClientSessionUser();

expect(window.sessionStorage.getItem(CLIENT_SESSION_USER_KEY)).toBeNull();
expect(window.localStorage.getItem(CLIENT_SESSION_USER_KEY)).toBeNull();
expect(readClientSessionUser()).toBeNull();
});

it('purgeLegacyClientSessionUser leaves the sessionStorage copy alone', () => {
seedClientSessionUser('seed@regengine.co');
window.localStorage.setItem(CLIENT_SESSION_USER_KEY, '{}');

purgeLegacyClientSessionUser();

expect(window.localStorage.getItem(CLIENT_SESSION_USER_KEY)).toBeNull();
expect(readClientSessionUser()?.email).toBe('seed@regengine.co');
});

it('ignores corrupt or incomplete stored data', () => {
window.sessionStorage.setItem(CLIENT_SESSION_USER_KEY, 'not-json');
expect(readClientSessionUser()).toBeNull();
window.sessionStorage.setItem(CLIENT_SESSION_USER_KEY, '{"email":"x@regengine.co"}');
expect(readClientSessionUser()).toBeNull();
});

it('rejects cached profiles with non-string optional fields', () => {
window.sessionStorage.setItem(
CLIENT_SESSION_USER_KEY,
JSON.stringify({ id: 'x', email: 'x@regengine.co', role_name: 5 }),
);
expect(readClientSessionUser()).toBeNull();

window.sessionStorage.setItem(
CLIENT_SESSION_USER_KEY,
JSON.stringify({ id: 'x', email: 'x@regengine.co', permissions: [1, 'ok'] }),
);
expect(readClientSessionUser()).toBeNull();
});
});
Loading
Loading