diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index d11ca33e..1008e6bc 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -8,6 +8,8 @@ import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; import TTPsObservedSection from './TTPsObservedSection'; import { useAttackerDetail } from './AttackerDetail/useAttackerDetail'; +import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader'; +import { Tag } from './AttackerDetail/ui'; import type { AttackerData, AttackerBehavior, @@ -88,17 +90,6 @@ const seqClassColor = (cls: string): string | undefined => { } }; -const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({ children, color }) => ( - - {children} - -); - const FpTlsHashes: React.FC<{ p: any }> = ({ p }) => (
@@ -1465,44 +1456,7 @@ const AttackerDetail: React.FC = () => { BACK TO PROFILES - {/* Header */} -
- -

- {attacker.ip} -

- {attacker.country_code && ( - - - {attacker.country_code} - - - )} - {attacker.is_traversal && ( - TRAVERSAL - )} - {/* Conditional Identity badge — surfaces only when the clusterer - has linked this observation to a resolved actor identity. - Zero behavior change when identity_id is null (which is - uniformly true until the clusterer ships). */} - {attacker.identity_id && ( - navigate(`/identities/${attacker.identity_id}`)} - > - IDENTITY · {attacker.identity_id.slice(0, 8)} - - )} -
+ {/* Stats Row */}
diff --git a/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.test.tsx b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.test.tsx new file mode 100644 index 00000000..2a74819b --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithRouter } from '../../../test/renderWithRouter'; +import { makeAttacker } from '../../../test/fixtures'; +import { AttackerHeader } from './AttackerHeader'; + +describe('AttackerHeader', () => { + it('renders the IP and a country tag when country_code is present', () => { + renderWithRouter(); + expect(screen.getByText('198.51.100.10')).toBeInTheDocument(); + expect(screen.getByText('BR')).toBeInTheDocument(); + }); + + it('omits the country tag when country_code is null', () => { + renderWithRouter( + , + ); + expect(screen.queryByText('BR')).not.toBeInTheDocument(); + expect(screen.queryByText('US')).not.toBeInTheDocument(); + }); + + it('shows the TRAVERSAL badge when is_traversal is true', () => { + renderWithRouter( + , + ); + expect(screen.getByText('TRAVERSAL')).toBeInTheDocument(); + }); + + it('renders the IDENTITY badge with first 8 chars and navigates on click', async () => { + const user = userEvent.setup(); + const identity = 'aaaabbbb-cccc-dddd-eeee-ffffffffffff'; + renderWithRouter( + , + ); + const badge = screen.getByText(/IDENTITY · aaaabbbb/); + expect(badge).toBeInTheDocument(); + // No assertion on navigation target; we just verify the click + // handler doesn't throw (router is present via renderWithRouter). + await user.click(badge); + }); + + it('omits the IDENTITY badge when identity_id is null', () => { + renderWithRouter( + , + ); + expect(screen.queryByText(/IDENTITY/)).not.toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.tsx b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.tsx new file mode 100644 index 00000000..feb67769 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/AttackerHeader.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Crosshair } from '../../../icons'; +import { Tag } from '../ui'; +import type { AttackerData } from '../types'; + +interface Props { + attacker: AttackerData; +} + +/** Page header: crosshair + IP + country / traversal / identity badges. + * The identity badge is click-through to the resolved-actor page. */ +export const AttackerHeader: React.FC = ({ attacker }) => { + const navigate = useNavigate(); + return ( +
+ +

+ {attacker.ip} +

+ {attacker.country_code && ( + + + {attacker.country_code} + + + )} + {attacker.is_traversal && ( + TRAVERSAL + )} + {attacker.identity_id && ( + navigate(`/identities/${attacker.identity_id}`)} + > + IDENTITY · {attacker.identity_id.slice(0, 8)} + + )} +
+ ); +}; diff --git a/decnet_web/src/components/AttackerDetail/ui.tsx b/decnet_web/src/components/AttackerDetail/ui.tsx new file mode 100644 index 00000000..f6eb8eaf --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/ui.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +/** Pill-style tag chip used throughout the AttackerDetail surface + * for badges, filters, and category labels. Color drives both the + * border and a 15%-alpha fill (the suffix is hex alpha). */ +export const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({ + children, + color, +}) => ( + + {children} + +); diff --git a/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts b/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts index 43a710f6..859c7e2b 100644 --- a/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts +++ b/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts @@ -25,9 +25,9 @@ import { useAttackerDetail } from './useAttackerDetail'; const ID = '11111111-1111-1111-1111-111111111111'; -const attackerHandler = (body: unknown, status = 200) => +const attackerHandler = (body: object, status = 200) => http.get(apiUrl(`/attackers/${ID}`), () => - HttpResponse.json(body, { status }), + HttpResponse.json(body as Record, { status }), ); const stockHandlers = () => [ @@ -139,9 +139,10 @@ describe('useAttackerDetail', () => { it('flags mailForbidden on 403', async () => { server.use( - ...stockHandlers().filter( - (h) => !h.info.path.endsWith('/mail'), - ), + ...stockHandlers().filter((h) => { + const p = h.info.path; + return typeof p === 'string' ? !p.endsWith('/mail') : true; + }), http.get(apiUrl(`/attackers/${ID}/mail`), () => HttpResponse.json({ detail: 'forbidden' }, { status: 403 }), ),