mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
fix(rbac): handle permission metadata failures (#1735)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
|
||||
const authenticated = { user: { username: 'operator', role: 'admin' } };
|
||||
const permissionData = {
|
||||
globalRole: 'viewer',
|
||||
globalPermissions: ['stack:read'],
|
||||
scopedPermissions: {},
|
||||
};
|
||||
|
||||
function mockFetch(...responses: Array<Response | Promise<Response>>) {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ needsSetup: false }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(authenticated), { status: 200 }))
|
||||
.mockImplementationOnce(() => responses.shift()));
|
||||
}
|
||||
|
||||
describe('AuthContext permission metadata', () => {
|
||||
it('keeps authorization unavailable until permissions load', async () => {
|
||||
let resolvePermissions: (response: Response) => void;
|
||||
const pendingPermissions = new Promise<Response>((resolve) => { resolvePermissions = resolve; });
|
||||
mockFetch(pendingPermissions);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider });
|
||||
|
||||
await waitFor(() => expect(result.current.appStatus).toBe('authenticated'));
|
||||
expect(result.current.permissionsStatus).toBe('loading');
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
expect(result.current.can('stack:read')).toBe(false);
|
||||
|
||||
await act(async () => resolvePermissions!(new Response(JSON.stringify(permissionData), { status: 200 })));
|
||||
|
||||
await waitFor(() => expect(result.current.permissionsStatus).toBe('ready'));
|
||||
expect(result.current.can('stack:read')).toBe(true);
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed and recovers after a retry', async () => {
|
||||
mockFetch(new Response(null, { status: 503 }));
|
||||
const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider });
|
||||
|
||||
await waitFor(() => expect(result.current.permissionsStatus).toBe('error'));
|
||||
expect(result.current.can('stack:read')).toBe(false);
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(permissionData), { status: 200 }));
|
||||
await act(async () => result.current.retryPermissions());
|
||||
|
||||
expect(result.current.permissionsStatus).toBe('ready');
|
||||
expect(result.current.can('stack:read')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||
import { markMilestone } from '@/lib/hydrationTiming';
|
||||
import { resolveCan } from '@/lib/resolveCan';
|
||||
|
||||
@@ -35,6 +35,7 @@ interface AuthContextType {
|
||||
permissionsStatus: PermissionsStatus;
|
||||
permissionsReady: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
|
||||
retryPermissions: () => Promise<void>;
|
||||
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
||||
ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
||||
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
|
||||
@@ -51,12 +52,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
|
||||
const [permissionsStatus, setPermissionsStatus] = useState<PermissionsStatus>('loading');
|
||||
const permissionRequestRef = useRef(0);
|
||||
|
||||
const resetPermissions = useCallback(() => {
|
||||
permissionRequestRef.current += 1;
|
||||
setPermissions(null);
|
||||
setPermissionsStatus('loading');
|
||||
}, []);
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
const requestId = ++permissionRequestRef.current;
|
||||
setPermissions(null);
|
||||
setPermissionsStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/permissions/me', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
console.error('[Auth] Permission metadata request failed:', response.status);
|
||||
if (requestId === permissionRequestRef.current) setPermissionsStatus('error');
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (requestId !== permissionRequestRef.current) return;
|
||||
setPermissions(data);
|
||||
setPermissionsStatus('ready');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Permission metadata request failed:', error);
|
||||
if (requestId === permissionRequestRef.current) setPermissionsStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
resetPermissions();
|
||||
try {
|
||||
@@ -79,26 +104,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authPromise = fetch('/api/auth/check', { credentials: 'include' });
|
||||
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' });
|
||||
|
||||
const authResponse = await authPromise;
|
||||
const authResponse = await fetch('/api/auth/check', { credentials: 'include' });
|
||||
if (authResponse.ok) {
|
||||
const data = await authResponse.json();
|
||||
setUser(data.user ?? null);
|
||||
setAppStatus('authenticated');
|
||||
|
||||
try {
|
||||
const res = await permsPromise;
|
||||
if (res.ok) {
|
||||
setPermissions(await res.json());
|
||||
setPermissionsStatus('ready');
|
||||
} else {
|
||||
setPermissionsStatus('error');
|
||||
}
|
||||
} catch {
|
||||
setPermissionsStatus('error');
|
||||
}
|
||||
await loadPermissions();
|
||||
} else {
|
||||
setUser(null);
|
||||
resetPermissions();
|
||||
@@ -134,7 +145,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
resourceType?: string,
|
||||
resourceId?: string,
|
||||
nodeId?: number | null,
|
||||
): boolean => resolveCan(permissions, action, resourceType, resourceId, nodeId), [permissions]);
|
||||
): boolean => {
|
||||
if (permissionsStatus !== 'ready' || !permissions) return false;
|
||||
return resolveCan(permissions, action, resourceType, resourceId, nodeId);
|
||||
}, [permissions, permissionsStatus]);
|
||||
|
||||
const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
|
||||
try {
|
||||
@@ -252,11 +266,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated: appStatus === 'authenticated',
|
||||
needsSetup: appStatus === 'needsSetup',
|
||||
user,
|
||||
isAdmin: user?.role === 'admin',
|
||||
isAdmin: permissionsStatus === 'ready' && permissions?.globalRole === 'admin',
|
||||
permissions,
|
||||
permissionsStatus,
|
||||
permissionsReady: permissionsStatus !== 'loading',
|
||||
permissionsReady: permissionsStatus === 'ready',
|
||||
can,
|
||||
retryPermissions: loadPermissions,
|
||||
login,
|
||||
ssoLdapLogin,
|
||||
submitMfa,
|
||||
|
||||
Reference in New Issue
Block a user