From e29a0094c9cfe6f96d84caf034abf5538037a0da Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:10:53 -0400 Subject: [PATCH] refactor(decnet_web/Credentials): add useCredentials data hook --- .../Credentials/useCredentials.test.ts | 96 +++++++++++++ .../components/Credentials/useCredentials.ts | 127 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 decnet_web/src/components/Credentials/useCredentials.test.ts create mode 100644 decnet_web/src/components/Credentials/useCredentials.ts diff --git a/decnet_web/src/components/Credentials/useCredentials.test.ts b/decnet_web/src/components/Credentials/useCredentials.test.ts new file mode 100644 index 00000000..4e688ae1 --- /dev/null +++ b/decnet_web/src/components/Credentials/useCredentials.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../test/server'; +import { useCredentials, type UseCredentialsArgs } from './useCredentials'; +import type { CredentialEntry, CredentialReuseRow } from './types'; + +const cred = (over: Partial = {}): CredentialEntry => ({ + id: 'c-1', last_seen: '2026-05-01T00:00:00Z', decky_name: 'd1', service: 'ssh', + attacker_ip: '1.2.3.4', principal: 'root', secret_sha256: 'a', + secret_kind: 'plaintext', secret_printable: 'p', attempt_count: 1, + ...over, +} as CredentialEntry); + +const reuse = (over: Partial = {}): CredentialReuseRow => ({ + id: 'r-1', last_seen: '2026-05-01T00:00:00Z', principal: 'root', + secret_sha256: 'a', secret_kind: 'plaintext', + target_count: 2, attempt_count: 5, deckies: ['d1', 'd2'], services: ['ssh'], + ...over, +} as CredentialReuseRow); + +const baseArgs: UseCredentialsArgs = { + tab: 'creds', page: 1, query: '', serviceFilter: '', refreshTick: 0, +}; + +describe('useCredentials', () => { + it('loads creds on the creds tab and builds the reuse map alongside', async () => { + server.use( + http.get(apiUrl('/credentials'), () => + HttpResponse.json({ data: [cred()], total: 1 }), + ), + http.get(apiUrl('/credential-reuse'), () => + HttpResponse.json({ data: [reuse()], total: 1 }), + ), + ); + const { result } = renderHook(() => useCredentials(baseArgs)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.creds).toHaveLength(1); + expect(result.current.credsTotal).toBe(1); + await waitFor(() => expect(result.current.reuseMap.size).toBe(1)); + expect(result.current.reuseMap.get('a|plaintext|root')?.target_count).toBe(2); + }); + + it('loads reuse rows on the reuse tab', async () => { + server.use( + http.get(apiUrl('/credential-reuse'), ({ request }) => { + const u = new URL(request.url); + const limit = u.searchParams.get('limit'); + return HttpResponse.json({ + data: [reuse({ id: limit === '25' ? 'page-row' : 'map-row' })], + total: 1, + }); + }), + ); + const { result } = renderHook(() => useCredentials({ ...baseArgs, tab: 'reuse' })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.reuseRows.some((r) => r.id === 'page-row')).toBe(true); + }); + + it('passes search/service/page to /credentials in the query string', async () => { + let captured: URL | null = null; + server.use( + http.get(apiUrl('/credentials'), ({ request }) => { + captured = new URL(request.url); + return HttpResponse.json({ data: [], total: 0 }); + }), + http.get(apiUrl('/credential-reuse'), () => + HttpResponse.json({ data: [], total: 0 }), + ), + ); + const { result } = renderHook(() => + useCredentials({ ...baseArgs, page: 3, query: 'admin', serviceFilter: 'ssh' }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + const url = captured as URL | null; + expect(url?.searchParams.get('search')).toBe('admin'); + expect(url?.searchParams.get('service')).toBe('ssh'); + expect(url?.searchParams.get('offset')).toBe('100'); // (3 - 1) * 50 + }); + + it('fetchReuseDetail returns the row on success', async () => { + server.use( + http.get(apiUrl('/credentials'), () => HttpResponse.json({ data: [], total: 0 })), + http.get(apiUrl('/credential-reuse'), () => HttpResponse.json({ data: [], total: 0 })), + http.get(apiUrl('/credential-reuse/r-9'), () => HttpResponse.json(reuse({ id: 'r-9' }))), + ); + const { result } = renderHook(() => useCredentials(baseArgs)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let row: CredentialReuseRow | null | undefined; + await act(async () => { row = await result.current.fetchReuseDetail('r-9'); }); + expect(row?.id).toBe('r-9'); + }); +}); diff --git a/decnet_web/src/components/Credentials/useCredentials.ts b/decnet_web/src/components/Credentials/useCredentials.ts new file mode 100644 index 00000000..2f407bef --- /dev/null +++ b/decnet_web/src/components/Credentials/useCredentials.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react'; +import api from '../../utils/api'; +import { + CREDS_LIMIT, REUSE_LIMIT, REUSE_MAP_CAP, reuseKey, +} from './helpers'; +import type { + CredentialEntry, CredentialReuseRow, ReuseMapEntry, Tab, +} from './types'; + +export interface UseCredentialsArgs { + tab: Tab; + page: number; + query: string; + serviceFilter: string; + /** Bumped by the page to force a refetch of every endpoint. */ + refreshTick: number; +} + +export interface UseCredentials { + creds: CredentialEntry[]; + credsTotal: number; + reuseRows: CredentialReuseRow[]; + reuseTotal: number; + reuseMap: Map; + loading: boolean; + /** Fetch a full reuse row by id (used when clicking a reuse badge + * on the creds tab — the map only stores enough to show the pill). */ + fetchReuseDetail: (id: string) => Promise; +} + +/** Owns the three credential fetches: the active tab's list (creds or + * reuse), and a small reuse-summary map that powers the reuse pill on + * the creds row. The page passes URL state in; the hook stays free + * of URL/router concerns. */ +export function useCredentials(args: UseCredentialsArgs): UseCredentials { + const { tab, page, query, serviceFilter, refreshTick } = args; + + const [creds, setCreds] = useState([]); + const [credsTotal, setCredsTotal] = useState(0); + const [reuseRows, setReuseRows] = useState([]); + const [reuseTotal, setReuseTotal] = useState(0); + const [reuseMap, setReuseMap] = useState>(new Map()); + const [loading, setLoading] = useState(true); + + // ── creds list (CREDS tab only) + useEffect(() => { + if (tab !== 'creds') return; + let cancelled = false; + (async () => { + setLoading(true); + try { + const offset = (page - 1) * CREDS_LIMIT; + let url = `/credentials?limit=${CREDS_LIMIT}&offset=${offset}`; + if (query) url += `&search=${encodeURIComponent(query)}`; + if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; + const res = await api.get(url); + if (cancelled) return; + setCreds(res.data.data); + setCredsTotal(res.data.total); + } catch (err) { + console.error('Failed to fetch credentials', err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [tab, query, serviceFilter, page, refreshTick]); + + // ── reuse list (REUSE tab only) + useEffect(() => { + if (tab !== 'reuse') return; + let cancelled = false; + (async () => { + setLoading(true); + try { + const offset = (page - 1) * REUSE_LIMIT; + const res = await api.get(`/credential-reuse?limit=${REUSE_LIMIT}&offset=${offset}`); + if (cancelled) return; + setReuseRows(res.data.data); + setReuseTotal(res.data.total); + } catch (err) { + console.error('Failed to fetch credential-reuse', err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [tab, page, refreshTick]); + + // ── reuse-map (always; powers the badge column on the CREDS tab) + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await api.get(`/credential-reuse?limit=${REUSE_MAP_CAP}&offset=0`); + if (cancelled) return; + const m = new Map(); + (res.data.data as CredentialReuseRow[]).forEach((r) => { + m.set(reuseKey(r.secret_sha256, r.secret_kind, r.principal), { + id: r.id, target_count: r.target_count, + }); + }); + setReuseMap(m); + } catch { + /* badge column degrades silently to "—" */ + } + })(); + return () => { cancelled = true; }; + }, [refreshTick]); + + const fetchReuseDetail = useCallback( + async (id: string): Promise => { + try { + const res = await api.get(`/credential-reuse/${id}`); + return res.data as CredentialReuseRow; + } catch (err) { + console.error('Failed to fetch reuse detail', err); + return null; + } + }, + [], + ); + + return { + creds, credsTotal, reuseRows, reuseTotal, reuseMap, loading, fetchReuseDetail, + }; +}