refactor(decnet_web/Credentials): add useCredentials data hook
This commit is contained in:
96
decnet_web/src/components/Credentials/useCredentials.test.ts
Normal file
96
decnet_web/src/components/Credentials/useCredentials.test.ts
Normal file
@@ -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> = {}): 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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
127
decnet_web/src/components/Credentials/useCredentials.ts
Normal file
127
decnet_web/src/components/Credentials/useCredentials.ts
Normal file
@@ -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<string, ReuseMapEntry>;
|
||||||
|
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<CredentialReuseRow | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<CredentialEntry[]>([]);
|
||||||
|
const [credsTotal, setCredsTotal] = useState(0);
|
||||||
|
const [reuseRows, setReuseRows] = useState<CredentialReuseRow[]>([]);
|
||||||
|
const [reuseTotal, setReuseTotal] = useState(0);
|
||||||
|
const [reuseMap, setReuseMap] = useState<Map<string, ReuseMapEntry>>(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<string, ReuseMapEntry>();
|
||||||
|
(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<CredentialReuseRow | null> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user