Skip to content

Commit 4143e25

Browse files
authored
Merge pull request #69 from Altamimi-Dev/63-gate-recent-activity
fix: gate the recent-activity fetch on audit:read
2 parents b5f8b47 + d967052 commit 4143e25

4 files changed

Lines changed: 60 additions & 6 deletions

File tree

src/hooks/useMiniCardStatuses.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ function health(over: { data?: VersionInfo; error?: { message: string; status?:
4141

4242
function admin(isAdmin = true) {
4343
mockUseAuth.mockReturnValue({
44-
hasPermission: (perm: string) => isAdmin && perm === "admin.system_config",
44+
hasPermission: (perm: string) =>
45+
isAdmin && (perm === "admin.system_config" || perm === "audit:read"),
4546
} as unknown as ReturnType<typeof useAuth>);
4647
}
4748

@@ -102,6 +103,20 @@ describe("useMiniCardStatuses — /version gating", () => {
102103
});
103104
});
104105

106+
describe("useMiniCardStatuses — activity gating", () => {
107+
it("does not fetch activity for a caller without audit:read", () => {
108+
admin(false);
109+
renderHook(() => useMiniCardStatuses());
110+
expect(mockUseRecentActivity).toHaveBeenCalledWith({ pollIntervalMs: 0, enabled: false });
111+
});
112+
113+
it("fetches activity when the caller holds audit:read", () => {
114+
admin(true);
115+
renderHook(() => useMiniCardStatuses());
116+
expect(mockUseRecentActivity).toHaveBeenCalledWith({ pollIntervalMs: 0, enabled: true });
117+
});
118+
});
119+
105120
describe("useMiniCardStatuses — headline health axis", () => {
106121
it("stays optimistic (reachable undefined) while health is loading / for a non-admin", () => {
107122
admin(false); // no /version -> no data, no error

src/hooks/useMiniCardStatuses.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
* `/version` is admin-only, so it is fetched only when the caller can view
1313
* system diagnostics (`admin.system_config`); non-admins never poll a guaranteed
1414
* 403. It is polled once here (the hook is resolved at the page level) and feeds
15-
* both the mini cards and the headline.
15+
* both the mini cards and the headline. Activity is gated the same way on
16+
* `audit:read`.
1617
*/
1718

1819
import { useMemo } from "react";
@@ -75,7 +76,11 @@ export function useMiniCardStatuses(): HomeStatus {
7576
const { data: health, error: healthError } = systemHealth;
7677
const { data: mcpServers, error: mcpServersError } = useQuery<ServersResponse>(MCP_REACH_PATH);
7778
const { data: a2aAgents, error: a2aError } = useQuery<Activatable[]>(A2A_REACH_PATH);
78-
const { items } = useRecentActivity({ pollIntervalMs: 0 });
79+
// /api/logs/activity requires audit:read, which no default non-admin role
80+
// holds. security:read is not checked: that half of the feed is additive
81+
// server-side, so an audit:read-only caller gets a narrower feed, not an error.
82+
const canViewActivity = hasPermission("audit:read");
83+
const { items } = useRecentActivity({ pollIntervalMs: 0, enabled: canViewActivity });
7984

8085
const derived = useMemo(() => {
8186
const healthy = safeHealthy(health);

src/hooks/useRecentActivity.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ describe("useRecentActivity", () => {
4141
expect(result.current.items).toEqual([]);
4242
});
4343

44+
it("makes no request while disabled and fetches once enabled", async () => {
45+
let callCount = 0;
46+
server.use(
47+
http.get("*/api/logs/activity", () => {
48+
callCount += 1;
49+
return HttpResponse.json({ items: RECENT_ACTIVITY_FIXTURE.slice(0, 2) });
50+
}),
51+
);
52+
53+
const { result, rerender } = renderHook(
54+
({ enabled }) => useRecentActivity({ pollIntervalMs: 0, enabled }),
55+
{ initialProps: { enabled: false } },
56+
);
57+
58+
await waitFor(() => expect(result.current.isLoading).toBe(false));
59+
expect(callCount).toBe(0);
60+
expect(result.current.items).toEqual([]);
61+
62+
rerender({ enabled: true });
63+
64+
await waitFor(() => expect(result.current.items).toHaveLength(2));
65+
expect(callCount).toBe(1);
66+
});
67+
4468
it("refetch re-hits the endpoint and clears the error", async () => {
4569
let callCount = 0;
4670
server.use(

src/hooks/useRecentActivity.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,16 @@ interface UseRecentActivityOptions {
2626
limit?: number;
2727
/** Polling cadence override. Pass 0 to disable. */
2828
pollIntervalMs?: number;
29+
/** When false, no request is made and the feed stays empty. */
30+
enabled?: boolean;
2931
}
3032

3133
function isMockEnabled(): boolean {
3234
return import.meta.env.VITE_USE_MOCK_ACTIVITY === "true";
3335
}
3436

3537
export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRecentActivityResult {
36-
const { limit = 10, pollIntervalMs = RECENT_ACTIVITY_POLL_INTERVAL_MS } = options;
38+
const { limit = 10, pollIntervalMs = RECENT_ACTIVITY_POLL_INTERVAL_MS, enabled = true } = options;
3739
const mock = isMockEnabled();
3840

3941
const [items, setItems] = useState<ActivityItem[]>([]);
@@ -68,6 +70,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe
6870
);
6971

7072
useEffect(() => {
73+
if (!enabled) {
74+
setItems([]);
75+
setError(null);
76+
setIsLoading(false);
77+
return;
78+
}
79+
7180
const controller = new AbortController();
7281
void fetchOnce(controller.signal);
7382

@@ -83,12 +92,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe
8392
controller.abort();
8493
window.clearInterval(intervalId);
8594
};
86-
}, [fetchOnce, mock, pollIntervalMs]);
95+
}, [fetchOnce, mock, pollIntervalMs, enabled]);
8796

8897
const refetch = useCallback(async (): Promise<void> => {
98+
if (!enabled) return;
8999
setIsLoading(true);
90100
await fetchOnce();
91-
}, [fetchOnce]);
101+
}, [fetchOnce, enabled]);
92102

93103
return { items, isLoading, error, refetch };
94104
}

0 commit comments

Comments
 (0)