test(decnet_web): MSW-based test foundation for UI refactor

Phase 0 of the decnet_web refactor: stand up an MSW server, fixtures,
and a router-aware render helper so the upcoming god-component splits
(AttackerDetail first) can land with same-commit test coverage.

- msw devDep + setupServer wired into src/test/setup.ts
- src/test/server.ts re-exports server, http, HttpResponse, apiUrl()
- src/test/fixtures/{attacker,decky,canary,topology}.ts factories
- src/test/renderWithRouter.tsx wraps MemoryRouter + ToastProvider
- baseline coverage thresholds (0%) in vite.config.ts; raise per PR
- coverage/ added to decnet_web/.gitignore

Existing Orchestrator/AttackerDetail/ThemeLab tests stay on vi.mock
and continue to pass; new tests use MSW.
This commit is contained in:
2026-05-09 04:30:51 -04:00
parent 3318b15044
commit 07a7d4918c
12 changed files with 751 additions and 2 deletions

View File

@@ -0,0 +1,58 @@
export interface AttackerFixture {
uuid: string;
ip: string;
identity_id: string | null;
first_seen: string;
last_seen: string;
event_count: number;
service_count: number;
decky_count: number;
services: string[];
deckies: string[];
traversal_path: string | null;
is_traversal: boolean;
bounty_count: number;
credential_count: number;
fingerprints: unknown[];
commands: { service: string; decky: string; command: string; timestamp: string }[];
country_code: string | null;
country_source: string | null;
asn: number | null;
as_name: string | null;
asn_source: string | null;
ptr_record: string | null;
updated_at: string;
behavior: null;
service_activity: { interacted: string[]; scanned: string[] };
observations: never[];
}
export const makeAttacker = (overrides: Partial<AttackerFixture> = {}): AttackerFixture => ({
uuid: '11111111-1111-1111-1111-111111111111',
ip: '198.51.100.10',
identity_id: null,
first_seen: '2026-05-01T10:00:00Z',
last_seen: '2026-05-09T11:00:00Z',
event_count: 12,
service_count: 2,
decky_count: 1,
services: ['ssh', 'http'],
deckies: ['decoy-01'],
traversal_path: null,
is_traversal: false,
bounty_count: 0,
credential_count: 0,
fingerprints: [],
commands: [],
country_code: 'US',
country_source: 'maxmind',
asn: 64500,
as_name: 'EXAMPLE-AS',
asn_source: 'maxmind',
ptr_record: null,
updated_at: '2026-05-09T11:00:00Z',
behavior: null,
service_activity: { interacted: ['ssh'], scanned: ['http'] },
observations: [],
...overrides,
});

63
decnet_web/src/test/fixtures/canary.ts vendored Normal file
View File

@@ -0,0 +1,63 @@
export interface CanaryTokenFixture {
uuid: string;
kind: 'http' | 'dns' | 'aws_passive';
decky_name: string;
topology_id: string | null;
blob_uuid: string | null;
instrumenter: string | null;
generator: string | null;
placement_path: string;
callback_token: string;
placed_at: string;
last_triggered_at: string | null;
trigger_count: number;
created_by: string;
state: 'planted' | 'revoked' | 'failed';
last_error: string | null;
}
export interface CanaryBlobFixture {
uuid: string;
sha256: string;
filename: string;
content_type: string;
size_bytes: number;
uploaded_by: string;
uploaded_at: string;
token_count: number;
}
export const makeCanaryToken = (
overrides: Partial<CanaryTokenFixture> = {},
): CanaryTokenFixture => ({
uuid: '22222222-2222-2222-2222-222222222222',
kind: 'http',
decky_name: 'decoy-01',
topology_id: null,
blob_uuid: null,
instrumenter: 'git_config',
generator: 'git_config',
placement_path: '/etc/.git/config',
callback_token: 'abc123token',
placed_at: '2026-05-01T10:00:00Z',
last_triggered_at: null,
trigger_count: 0,
created_by: 'admin',
state: 'planted',
last_error: null,
...overrides,
});
export const makeCanaryBlob = (
overrides: Partial<CanaryBlobFixture> = {},
): CanaryBlobFixture => ({
uuid: '33333333-3333-3333-3333-333333333333',
sha256: 'a'.repeat(64),
filename: 'creds.json',
content_type: 'application/json',
size_bytes: 1024,
uploaded_by: 'admin',
uploaded_at: '2026-05-01T10:00:00Z',
token_count: 0,
...overrides,
});

31
decnet_web/src/test/fixtures/decky.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
export interface DeckyFixture {
name: string;
ip: string;
services: string[];
distro: string;
hostname: string;
archetype: string | null;
service_config: Record<string, Record<string, unknown>>;
mutate_interval: number | null;
last_mutated: number;
swarm?: {
host_uuid: string;
host_name: string;
state: string;
last_error: string | null;
last_seen: string | null;
};
}
export const makeDecky = (overrides: Partial<DeckyFixture> = {}): DeckyFixture => ({
name: 'decoy-01',
ip: '10.10.10.10',
services: ['ssh', 'http'],
distro: 'debian-12',
hostname: 'fileserver-01',
archetype: 'workstation',
service_config: {},
mutate_interval: null,
last_mutated: 0,
...overrides,
});

9
decnet_web/src/test/fixtures/index.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
export { makeAttacker, type AttackerFixture } from './attacker';
export { makeDecky, type DeckyFixture } from './decky';
export {
makeCanaryToken,
makeCanaryBlob,
type CanaryTokenFixture,
type CanaryBlobFixture,
} from './canary';
export { makeTopology, type TopologyFixture } from './topology';

View File

@@ -0,0 +1,18 @@
export interface TopologyFixture {
id: string;
name: string;
mode: string;
target_host_uuid: string | null;
status: string;
version: number;
}
export const makeTopology = (overrides: Partial<TopologyFixture> = {}): TopologyFixture => ({
id: '44444444-4444-4444-4444-444444444444',
name: 'corp-net-01',
mode: 'flat',
target_host_uuid: null,
status: 'active',
version: 1,
...overrides,
});

View File

@@ -0,0 +1,34 @@
import type { ReactElement, ReactNode } from 'react';
import { render, type RenderOptions, type RenderResult } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ToastProvider } from '../components/Toasts/ToastProvider';
export interface RenderWithRouterOptions extends Omit<RenderOptions, 'wrapper'> {
/** Initial URL the MemoryRouter starts at. */
initialEntries?: string[];
/** When set, the rendered UI is mounted at this route path so `useParams` resolves. */
path?: string;
}
const Wrap = ({ children }: { children: ReactNode }) => (
<ToastProvider>{children}</ToastProvider>
);
export const renderWithRouter = (
ui: ReactElement,
{ initialEntries = ['/'], path, ...rest }: RenderWithRouterOptions = {},
): RenderResult => {
const tree = path ? (
<Routes>
<Route path={path} element={ui} />
</Routes>
) : (
ui
);
return render(
<MemoryRouter initialEntries={initialEntries}>
<Wrap>{tree}</Wrap>
</MemoryRouter>,
rest,
);
};

View File

@@ -0,0 +1,13 @@
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const API_BASE = 'http://localhost:8000/api/v1';
export const server = setupServer();
export { http, HttpResponse };
export const apiUrl = (path: string): string => {
const trimmed = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE}${trimmed}`;
};

View File

@@ -1,7 +1,13 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { cleanup } from '@testing-library/react';
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());