refactor(decnet_web/AttackerDetail): extract AttackerHeader section
Lift the header (IP, country tag, traversal badge, identity badge) into its own section component. Tag helper moves to a shared AttackerDetail/ui.tsx so future sections can reuse it without re-importing through AttackerDetail.tsx. - New AttackerDetail/sections/AttackerHeader.tsx (~50 LOC) - New AttackerDetail/ui.tsx for shared presentational helpers - AttackerDetail.tsx imports both; local Tag definition deleted - AttackerHeader.test.tsx covers country present/absent, TRAVERSAL badge, IDENTITY click-through, identity null path
This commit is contained in:
@@ -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(<AttackerHeader attacker={makeAttacker({ country_code: 'BR' })} />);
|
||||
expect(screen.getByText('198.51.100.10')).toBeInTheDocument();
|
||||
expect(screen.getByText('BR')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the country tag when country_code is null', () => {
|
||||
renderWithRouter(
|
||||
<AttackerHeader attacker={makeAttacker({ country_code: null })} />,
|
||||
);
|
||||
expect(screen.queryByText('BR')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('US')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the TRAVERSAL badge when is_traversal is true', () => {
|
||||
renderWithRouter(
|
||||
<AttackerHeader attacker={makeAttacker({ is_traversal: true })} />,
|
||||
);
|
||||
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(
|
||||
<AttackerHeader attacker={makeAttacker({ identity_id: identity })} />,
|
||||
);
|
||||
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(
|
||||
<AttackerHeader attacker={makeAttacker({ identity_id: null })} />,
|
||||
);
|
||||
expect(screen.queryByText(/IDENTITY/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<Props> = ({ attacker }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Crosshair size={32} className="violet-accent" />
|
||||
<h1 className="matrix-text" style={{ fontSize: '1.8rem', letterSpacing: '2px' }}>
|
||||
{attacker.ip}
|
||||
</h1>
|
||||
{attacker.country_code && (
|
||||
<Tag color="var(--text-color)">
|
||||
<span
|
||||
title={attacker.country_source ? `source: ${attacker.country_source}` : undefined}
|
||||
style={{ letterSpacing: '2px' }}
|
||||
>
|
||||
{attacker.country_code}
|
||||
</span>
|
||||
</Tag>
|
||||
)}
|
||||
{attacker.is_traversal && (
|
||||
<span className="traversal-badge" style={{ fontSize: '0.8rem' }}>TRAVERSAL</span>
|
||||
)}
|
||||
{attacker.identity_id && (
|
||||
<span
|
||||
className="traversal-badge"
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '2px',
|
||||
}}
|
||||
title="Resolved identity — click to view all observations linked to this actor"
|
||||
onClick={() => navigate(`/identities/${attacker.identity_id}`)}
|
||||
>
|
||||
IDENTITY · {attacker.identity_id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
decnet_web/src/components/AttackerDetail/ui.tsx
Normal file
22
decnet_web/src/components/AttackerDetail/ui.tsx
Normal file
@@ -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,
|
||||
}) => (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
padding: '2px 8px',
|
||||
letterSpacing: '1px',
|
||||
border: `1px solid ${color || 'var(--text-color)'}`,
|
||||
color: color || 'var(--text-color)',
|
||||
background: `${color || 'var(--text-color)'}15`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -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<string, unknown>, { 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 }),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user