mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
fix(fleet-sync): open status and reset-anchor to Community admins (#1792)
Baseline Fleet Sync already replicates security policy on Community code paths, but status and anchor recovery still required a paid entitlement. Drop the residual paid gates while keeping admin and node:manage authorization boundaries.
This commit is contained in:
@@ -2,15 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
const fetchStatusesMock = vi.fn();
|
||||
let mockIsPaid = false;
|
||||
let mockIsAdmin = false;
|
||||
|
||||
vi.mock('@/lib/fleetSyncApi', () => ({
|
||||
fetchFleetSyncStatuses: (...args: unknown[]) => fetchStatusesMock(...args),
|
||||
}));
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => ({ isPaid: mockIsPaid }),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: mockIsAdmin }),
|
||||
}));
|
||||
@@ -24,14 +20,12 @@ import { useFleetSyncStatus } from '../useFleetSyncStatus';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchStatusesMock.mockReset().mockResolvedValue([{ node_id: 1, resource: 'scan_policies' }]);
|
||||
mockIsPaid = false;
|
||||
mockIsAdmin = false;
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('useFleetSyncStatus gate parity', () => {
|
||||
it('fetches for a paid admin (mirrors requireAdmin + requirePaid)', async () => {
|
||||
mockIsPaid = true;
|
||||
describe('useFleetSyncStatus admin parity', () => {
|
||||
it('fetches for an admin (mirrors requireAdmin on sync-status)', async () => {
|
||||
mockIsAdmin = true;
|
||||
const { result } = renderHook(() => useFleetSyncStatus());
|
||||
|
||||
@@ -40,8 +34,7 @@ describe('useFleetSyncStatus gate parity', () => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fetch for a paid non-admin (the 403-loop this fix prevents)', async () => {
|
||||
mockIsPaid = true;
|
||||
it('does not fetch for a non-admin (avoids a 403 poll loop)', async () => {
|
||||
mockIsAdmin = false;
|
||||
const { result } = renderHook(() => useFleetSyncStatus());
|
||||
|
||||
@@ -50,28 +43,10 @@ describe('useFleetSyncStatus gate parity', () => {
|
||||
expect(result.current.statuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not fetch for an admin on a community license', async () => {
|
||||
mockIsPaid = false;
|
||||
mockIsAdmin = true;
|
||||
const { result } = renderHook(() => useFleetSyncStatus());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetchStatusesMock).not.toHaveBeenCalled();
|
||||
expect(result.current.statuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not fetch when neither paid nor admin', async () => {
|
||||
const { result } = renderHook(() => useFleetSyncStatus());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetchStatusesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores rows from a fetch that resolves after the gate flips ineligible', async () => {
|
||||
it('ignores rows from a fetch that resolves after admin is lost', async () => {
|
||||
let resolveFetch!: (rows: Array<{ node_id: number; resource: string }>) => void;
|
||||
const pending = new Promise<Array<{ node_id: number; resource: string }>>((res) => { resolveFetch = res; });
|
||||
fetchStatusesMock.mockReturnValue(pending);
|
||||
mockIsPaid = true;
|
||||
mockIsAdmin = true;
|
||||
const { result, rerender } = renderHook(() => useFleetSyncStatus());
|
||||
expect(fetchStatusesMock).toHaveBeenCalled();
|
||||
@@ -82,7 +57,7 @@ describe('useFleetSyncStatus gate parity', () => {
|
||||
act(() => { rerender(); });
|
||||
expect(result.current.statuses).toEqual([]);
|
||||
|
||||
// The late resolve must not republish rows to the now-ineligible client.
|
||||
// Late resolve must not republish rows to a non-admin client.
|
||||
await act(async () => {
|
||||
resolveFetch([{ node_id: 9, resource: 'scan_policies' }]);
|
||||
await Promise.resolve();
|
||||
@@ -91,12 +66,11 @@ describe('useFleetSyncStatus gate parity', () => {
|
||||
expect(result.current.statuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts fetching once the user becomes a paid admin', async () => {
|
||||
it('starts fetching once the user becomes an admin', async () => {
|
||||
const { result, rerender } = renderHook(() => useFleetSyncStatus());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetchStatusesMock).not.toHaveBeenCalled();
|
||||
|
||||
mockIsPaid = true;
|
||||
mockIsAdmin = true;
|
||||
act(() => { rerender(); });
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
import { fetchFleetSyncStatuses, type FleetSyncStatus } from '@/lib/fleetSyncApi';
|
||||
@@ -8,48 +7,42 @@ const REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Polls `/api/fleet/sync-status` and exposes the rows plus a manual
|
||||
* `refresh()`. The endpoint requires a paid admin, so the hook only fetches
|
||||
* for paid admins and returns an empty array otherwise. This mirrors the
|
||||
* route guard exactly: gating on tier alone would leave a paid non-admin
|
||||
* polling an endpoint that always 403s.
|
||||
* `refresh()`. Admin-only, matching the route's requireAdmin guard: non-admins
|
||||
* get an empty array instead of polling an endpoint that always 403s.
|
||||
*/
|
||||
export function useFleetSyncStatus(): {
|
||||
statuses: FleetSyncStatus[];
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
} {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const canQuery = isPaid && isAdmin;
|
||||
// Reflects the latest gate state so a fetch begun while eligible cannot
|
||||
// publish its rows after the user loses eligibility mid-flight (role or
|
||||
// tier change). Without this, a late resolve would re-populate statuses
|
||||
// that the gate-false path already cleared.
|
||||
const canQueryRef = useRef(canQuery);
|
||||
canQueryRef.current = canQuery;
|
||||
// Latest eligibility for in-flight fetches: a request started while admin
|
||||
// must not publish rows after a mid-flight demotion already cleared state.
|
||||
const isAdminRef = useRef(isAdmin);
|
||||
isAdminRef.current = isAdmin;
|
||||
const [statuses, setStatuses] = useState<FleetSyncStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!canQuery) {
|
||||
if (!isAdmin) {
|
||||
setStatuses([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchFleetSyncStatuses()
|
||||
.then((rows) => { if (canQueryRef.current) setStatuses(rows); })
|
||||
.then((rows) => { if (isAdminRef.current) setStatuses(rows); })
|
||||
.catch((err) => {
|
||||
// Stale data stays visible to avoid flicker; log so the failure isn't completely silent.
|
||||
console.warn('[FleetSync] sync-status fetch failed:', err);
|
||||
})
|
||||
.finally(() => { if (canQueryRef.current) setLoading(false); });
|
||||
}, [canQuery]);
|
||||
.finally(() => { if (isAdminRef.current) setLoading(false); });
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
if (!canQuery) return undefined;
|
||||
if (!isAdmin) return undefined;
|
||||
return visibilityInterval(refresh, REFRESH_INTERVAL_MS);
|
||||
}, [canQuery, refresh]);
|
||||
}, [isAdmin, refresh]);
|
||||
|
||||
return { statuses, loading, refresh };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user