Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
4987 lines
168 KiB
Markdown
4987 lines
168 KiB
Markdown
# Huntress Integration Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Huntress SOC incident reports automatically become routed, self-contained Alga tickets via a polling engine, with fail-safe org→client mapping.
|
||
|
||
**Architecture:** EE-only integration reusing the `rmm_*` tables with `provider='huntress'`. A pg-boss dispatcher job ticks every 5 minutes, polls the Huntress REST API (Basic auth, cursor-walk over `updated_at`-sorted incident reports), upserts `rmm_alerts`, and creates/updates tickets per a pure decision planner. Spec: `docs/superpowers/specs/2026-06-09-huntress-integration-design.md`.
|
||
|
||
**Tech Stack:** Next.js server actions, Knex, axios, pg-boss (`JobScheduler`), Vitest, `@alga-psa/ui` components.
|
||
|
||
**Working rules for every task:**
|
||
- All commands run from the repo root unless stated otherwise.
|
||
- Commit after each task with the message given in the task.
|
||
- Set git author if needed: `git -c user.email="robert@nineminds.com" -c user.name="Robert Isaacs" commit ...`
|
||
- The integration test task requires the dev Postgres running (the worktree's Docker dev stack; `DB_PORT` defaults to 5432).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New files (EE logic):**
|
||
|
||
| File | Responsibility |
|
||
| --- | --- |
|
||
| `ee/server/src/interfaces/huntress.interfaces.ts` | Huntress API response types |
|
||
| `ee/server/src/lib/integrations/huntress/settings.ts` | `HuntressSettings` parse/validate/prefill + poll-due check (pure) |
|
||
| `ee/server/src/lib/integrations/huntress/organizations/nameMatch.ts` | Org-name normalization + exact-match (pure) |
|
||
| `ee/server/src/lib/integrations/huntress/huntressClient.ts` | REST client: Basic auth, throttle, 429 retry, pagination |
|
||
| `ee/server/src/lib/integrations/huntress/incidents/cursorWalk.ts` | Cursor-bounded page collection (pure, takes a fetch-page fn) |
|
||
| `ee/server/src/lib/integrations/huntress/incidents/ticketContent.ts` | Title/body/note/portal-link builders (pure) |
|
||
| `ee/server/src/lib/integrations/huntress/incidents/incidentPlan.ts` | Pure lifecycle decision: create/fallback/note/close/record/skip |
|
||
| `ee/server/src/lib/integrations/huntress/incidents/ticketCreator.ts` | Transactional ticket + thread/comment inserts |
|
||
| `ee/server/src/lib/integrations/huntress/incidents/incidentProcessor.ts` | Executes a plan against the DB for one incident |
|
||
| `ee/server/src/lib/integrations/huntress/incidents/incidentPoller.ts` | Per-integration poll cycle, cursor persistence, transport wrapper |
|
||
| `ee/server/src/lib/integrations/huntress/organizations/orgSync.ts` | Org fetch → mapping upsert + auto-match |
|
||
| `ee/server/src/lib/integrations/huntress/scheduling.ts` | pg-boss dispatcher registration + per-integration due-check |
|
||
| `ee/server/src/lib/integrations/huntress/index.ts` | Public exports |
|
||
| `ee/server/src/lib/actions/integrations/huntressActions.ts` | Server actions (connect, status, settings, mappings, poll-now) |
|
||
|
||
**New files (UI + CE stubs):**
|
||
|
||
| File | Responsibility |
|
||
| --- | --- |
|
||
| `ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx` | Connect card, status, routing config |
|
||
| `ee/server/src/components/settings/integrations/huntress/OrganizationMappingManager.tsx` | Org→client mapping table |
|
||
| `packages/ee/src/components/settings/integrations/HuntressIntegrationSettings.tsx` | CE stub ("Enterprise feature") |
|
||
| `packages/ee/src/lib/integrations/huntress/scheduling.ts` | CE no-op stub for job registration |
|
||
|
||
**Modified files:**
|
||
|
||
| File | Change |
|
||
| --- | --- |
|
||
| `packages/types/src/interfaces/asset.interfaces.ts:24` | add `'huntress'` to `RmmProvider` |
|
||
| `ee/server/src/interfaces/rmm.interfaces.ts:9` | add `'huntress'` to `RmmProvider` |
|
||
| `packages/integrations/src/lib/rmm/providerRegistry.ts` | `category` field + huntress entry |
|
||
| `packages/integrations/src/components/settings/integrations/RmmIntegrationsSetup.tsx` | category sections + huntress dynamic import |
|
||
| `server/src/lib/initializeApp.ts` | EE-gated registration of the Huntress poll dispatcher |
|
||
|
||
**Tests:**
|
||
|
||
| File | Covers |
|
||
| --- | --- |
|
||
| `ee/server/src/__tests__/unit/huntress/settings.test.ts` | parse/validate/prefill/poll-due |
|
||
| `ee/server/src/__tests__/unit/huntress/nameMatch.test.ts` | normalization + exact match |
|
||
| `ee/server/src/__tests__/unit/huntress/cursorWalk.test.ts` | pagination, cursor boundary, backfill, ordering |
|
||
| `ee/server/src/__tests__/unit/huntress/ticketContent.test.ts` | title/body/note/link builders |
|
||
| `ee/server/src/__tests__/unit/huntress/incidentPlan.test.ts` | every lifecycle decision |
|
||
| `ee/server/src/__tests__/unit/huntress/huntressClient.test.ts` | auth header, throttle, 429 retry, pagination, 404→null |
|
||
| `ee/server/src/__tests__/unit/huntress/incidentPoller.test.ts` | cursor advance, stop-on-failure, sync status |
|
||
| `ee/server/src/__tests__/integration/huntressIncidentProcessor.integration.test.ts` | DB end-to-end: create/dedup/note/close/fallback |
|
||
| `ee/server/src/__tests__/integration/huntressOrgSync.integration.test.ts` | DB: mapping upsert + auto-match |
|
||
|
||
---
|
||
|
||
### Task 1: Provider type + Huntress API interfaces
|
||
|
||
**Files:**
|
||
- Modify: `packages/types/src/interfaces/asset.interfaces.ts:24`
|
||
- Modify: `ee/server/src/interfaces/rmm.interfaces.ts:9`
|
||
- Create: `ee/server/src/interfaces/huntress.interfaces.ts`
|
||
|
||
- [ ] **Step 1: Add `'huntress'` to both `RmmProvider` types**
|
||
|
||
In `packages/types/src/interfaces/asset.interfaces.ts` line 24, change:
|
||
|
||
```typescript
|
||
export type RmmProvider = 'ninjaone' | 'tacticalrmm' | 'tanium' | 'datto' | 'connectwise_automate';
|
||
```
|
||
|
||
to:
|
||
|
||
```typescript
|
||
export type RmmProvider = 'ninjaone' | 'tacticalrmm' | 'tanium' | 'datto' | 'connectwise_automate' | 'huntress';
|
||
```
|
||
|
||
In `ee/server/src/interfaces/rmm.interfaces.ts` line 9, make the same change (the type is duplicated there).
|
||
|
||
- [ ] **Step 2: Create `ee/server/src/interfaces/huntress.interfaces.ts`**
|
||
|
||
```typescript
|
||
/**
|
||
* Huntress public API types.
|
||
*
|
||
* Shapes follow the Huntress OpenAPI spec (api.huntress.io). List endpoints
|
||
* wrap results: { incident_reports: [...], pagination: { next_page_token } }.
|
||
*/
|
||
|
||
export type HuntressSeverity = 'low' | 'high' | 'critical';
|
||
|
||
export type HuntressIncidentStatus =
|
||
| 'sent'
|
||
| 'closed'
|
||
| 'dismissed'
|
||
| 'auto_remediating'
|
||
| 'deleting'
|
||
| 'partner_dismissed';
|
||
|
||
export interface HuntressRemediationParameter {
|
||
name: string;
|
||
description: string;
|
||
}
|
||
|
||
export interface HuntressRemediation {
|
||
id: number;
|
||
type: string;
|
||
action?: string;
|
||
status?: string;
|
||
parameters?: HuntressRemediationParameter[];
|
||
approved_at?: string | null;
|
||
completed_at?: string | null;
|
||
}
|
||
|
||
export interface HuntressIncidentReport {
|
||
id: number;
|
||
account_id: number;
|
||
agent_id: number | null;
|
||
organization_id: number | null;
|
||
subject: string | null;
|
||
summary: string | null;
|
||
body: string | null;
|
||
severity: HuntressSeverity;
|
||
status: HuntressIncidentStatus;
|
||
platform: string | null;
|
||
indicator_types: string[];
|
||
indicator_counts: Record<string, number>;
|
||
remediations?: {
|
||
total_count: number;
|
||
has_more: boolean;
|
||
items: HuntressRemediation[];
|
||
};
|
||
sent_at: string | null;
|
||
closed_at: string | null;
|
||
status_updated_at: string | null;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface HuntressOrganization {
|
||
id: number;
|
||
name: string;
|
||
key?: string;
|
||
}
|
||
|
||
export interface HuntressAgent {
|
||
id: number;
|
||
hostname: string | null;
|
||
platform?: string | null;
|
||
os?: string | null;
|
||
ipv4_address?: string | null;
|
||
external_ip?: string | null;
|
||
serial_number?: string | null;
|
||
last_callback_at?: string | null;
|
||
}
|
||
|
||
export interface HuntressAccount {
|
||
id: number;
|
||
name: string;
|
||
subdomain: string;
|
||
}
|
||
|
||
export interface HuntressPagination {
|
||
next_page_token?: string | null;
|
||
next_page_url?: string | null;
|
||
}
|
||
|
||
export interface HuntressIncidentReportsPage {
|
||
incident_reports: HuntressIncidentReport[];
|
||
pagination?: HuntressPagination;
|
||
}
|
||
|
||
export interface HuntressOrganizationsPage {
|
||
organizations: HuntressOrganization[];
|
||
pagination?: HuntressPagination;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `cd ee/server && npm run typecheck`
|
||
Expected: same result as before the change (no new errors; pre-existing errors, if any, are unrelated).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add packages/types/src/interfaces/asset.interfaces.ts ee/server/src/interfaces/rmm.interfaces.ts ee/server/src/interfaces/huntress.interfaces.ts
|
||
git commit -m "feat(huntress): add provider type and Huntress API interfaces"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Settings parsing, validation, prefill, poll-due (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/settings.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/settings.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/settings.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
parseHuntressSettings,
|
||
isRoutingConfigComplete,
|
||
prefillSeverityPriorityMap,
|
||
isPollDue,
|
||
} from '@ee/lib/integrations/huntress/settings';
|
||
|
||
describe('parseHuntressSettings', () => {
|
||
it('applies defaults to an empty object', () => {
|
||
const s = parseHuntressSettings({});
|
||
expect(s.pollIntervalMinutes).toBe(5);
|
||
expect(s.backfillDays).toBe(7);
|
||
expect(s.autoCloseTickets).toBe(false);
|
||
expect(s.severityPriorityMap).toEqual({});
|
||
expect(s.incidentCursor).toBeUndefined();
|
||
});
|
||
|
||
it('accepts a JSON string (jsonb may arrive serialized)', () => {
|
||
const s = parseHuntressSettings(JSON.stringify({ pollIntervalMinutes: 10 }));
|
||
expect(s.pollIntervalMinutes).toBe(10);
|
||
});
|
||
|
||
it('tolerates null/undefined/garbage', () => {
|
||
expect(parseHuntressSettings(null).pollIntervalMinutes).toBe(5);
|
||
expect(parseHuntressSettings(undefined).backfillDays).toBe(7);
|
||
expect(parseHuntressSettings('not json').autoCloseTickets).toBe(false);
|
||
});
|
||
|
||
it('clamps pollIntervalMinutes to [1, 60] and backfillDays to [1, 30]', () => {
|
||
expect(parseHuntressSettings({ pollIntervalMinutes: 0 }).pollIntervalMinutes).toBe(1);
|
||
expect(parseHuntressSettings({ pollIntervalMinutes: 999 }).pollIntervalMinutes).toBe(60);
|
||
expect(parseHuntressSettings({ backfillDays: 0 }).backfillDays).toBe(1);
|
||
expect(parseHuntressSettings({ backfillDays: 90 }).backfillDays).toBe(30);
|
||
});
|
||
|
||
it('preserves configured routing fields', () => {
|
||
const s = parseHuntressSettings({
|
||
boardId: 'b1',
|
||
fallbackClientId: 'c1',
|
||
fallbackBoardId: 'b2',
|
||
severityPriorityMap: { critical: 'p1', high: 'p2', low: 'p3' },
|
||
autoCloseTickets: true,
|
||
closedStatusId: 's1',
|
||
accountSubdomain: 'acme',
|
||
incidentCursor: '2026-06-01T00:00:00Z',
|
||
});
|
||
expect(s.boardId).toBe('b1');
|
||
expect(s.fallbackClientId).toBe('c1');
|
||
expect(s.fallbackBoardId).toBe('b2');
|
||
expect(s.severityPriorityMap.critical).toBe('p1');
|
||
expect(s.autoCloseTickets).toBe(true);
|
||
expect(s.closedStatusId).toBe('s1');
|
||
expect(s.accountSubdomain).toBe('acme');
|
||
expect(s.incidentCursor).toBe('2026-06-01T00:00:00Z');
|
||
});
|
||
});
|
||
|
||
describe('isRoutingConfigComplete', () => {
|
||
const complete = parseHuntressSettings({
|
||
boardId: 'b1',
|
||
fallbackClientId: 'c1',
|
||
fallbackBoardId: 'b2',
|
||
severityPriorityMap: { critical: 'p1', high: 'p2', low: 'p3' },
|
||
});
|
||
|
||
it('true when board, fallback client/board, and all three severities are set', () => {
|
||
expect(isRoutingConfigComplete(complete)).toBe(true);
|
||
});
|
||
|
||
it('false when any required piece is missing', () => {
|
||
expect(isRoutingConfigComplete({ ...complete, boardId: undefined })).toBe(false);
|
||
expect(isRoutingConfigComplete({ ...complete, fallbackClientId: undefined })).toBe(false);
|
||
expect(isRoutingConfigComplete({ ...complete, fallbackBoardId: undefined })).toBe(false);
|
||
expect(
|
||
isRoutingConfigComplete({ ...complete, severityPriorityMap: { critical: 'p1', high: 'p2' } })
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('prefillSeverityPriorityMap', () => {
|
||
it('matches by name, case-insensitively, with preference order', () => {
|
||
const priorities = [
|
||
{ priority_id: 'p-med', priority_name: 'Medium' },
|
||
{ priority_id: 'p-high', priority_name: 'HIGH' },
|
||
{ priority_id: 'p-crit', priority_name: 'Critical' },
|
||
];
|
||
const map = prefillSeverityPriorityMap(priorities);
|
||
expect(map).toEqual({ critical: 'p-crit', high: 'p-high', low: 'p-med' });
|
||
});
|
||
|
||
it('prefers Urgent for critical when Critical is absent', () => {
|
||
const map = prefillSeverityPriorityMap([
|
||
{ priority_id: 'p-urg', priority_name: 'Urgent' },
|
||
]);
|
||
expect(map.critical).toBe('p-urg');
|
||
});
|
||
|
||
it('leaves severities unset when nothing matches', () => {
|
||
const map = prefillSeverityPriorityMap([{ priority_id: 'x', priority_name: 'Weird' }]);
|
||
expect(map).toEqual({});
|
||
});
|
||
});
|
||
|
||
describe('isPollDue', () => {
|
||
const now = new Date('2026-06-09T12:00:00Z');
|
||
|
||
it('true when never synced', () => {
|
||
expect(isPollDue(null, 5, now)).toBe(true);
|
||
expect(isPollDue(undefined, 5, now)).toBe(true);
|
||
});
|
||
|
||
it('true when the interval has elapsed', () => {
|
||
expect(isPollDue('2026-06-09T11:54:59Z', 5, now)).toBe(true);
|
||
});
|
||
|
||
it('false when within the interval', () => {
|
||
expect(isPollDue('2026-06-09T11:58:00Z', 5, now)).toBe(false);
|
||
});
|
||
|
||
it('accepts Date input for lastSyncAt', () => {
|
||
expect(isPollDue(new Date('2026-06-09T11:00:00Z'), 5, now)).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/settings.test.ts`
|
||
Expected: FAIL — cannot resolve `@ee/lib/integrations/huntress/settings`.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/settings.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Huntress integration settings stored in rmm_integrations.settings (JSONB).
|
||
* Pure parsing/validation helpers — no I/O.
|
||
*/
|
||
|
||
export interface HuntressSeverityPriorityMap {
|
||
critical?: string;
|
||
high?: string;
|
||
low?: string;
|
||
}
|
||
|
||
export interface HuntressSettings {
|
||
accountName?: string;
|
||
accountSubdomain?: string;
|
||
/** Max incident updated_at fully processed (ISO-8601). */
|
||
incidentCursor?: string;
|
||
pollIntervalMinutes: number;
|
||
backfillDays: number;
|
||
severityPriorityMap: HuntressSeverityPriorityMap;
|
||
boardId?: string;
|
||
categoryId?: string | null;
|
||
subcategoryId?: string | null;
|
||
fallbackClientId?: string;
|
||
fallbackBoardId?: string;
|
||
autoCloseTickets: boolean;
|
||
closedStatusId?: string | null;
|
||
}
|
||
|
||
const DEFAULT_POLL_INTERVAL_MINUTES = 5;
|
||
const DEFAULT_BACKFILL_DAYS = 7;
|
||
|
||
function clamp(value: number, min: number, max: number): number {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function asString(value: unknown): string | undefined {
|
||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||
}
|
||
|
||
export function parseHuntressSettings(raw: unknown): HuntressSettings {
|
||
let obj: Record<string, unknown> = {};
|
||
if (typeof raw === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') obj = parsed as Record<string, unknown>;
|
||
} catch {
|
||
// fall through to defaults
|
||
}
|
||
} else if (raw && typeof raw === 'object') {
|
||
obj = raw as Record<string, unknown>;
|
||
}
|
||
|
||
const rawMap = (obj.severityPriorityMap ?? {}) as Record<string, unknown>;
|
||
const severityPriorityMap: HuntressSeverityPriorityMap = {};
|
||
if (asString(rawMap.critical)) severityPriorityMap.critical = rawMap.critical as string;
|
||
if (asString(rawMap.high)) severityPriorityMap.high = rawMap.high as string;
|
||
if (asString(rawMap.low)) severityPriorityMap.low = rawMap.low as string;
|
||
|
||
const pollInterval = Number(obj.pollIntervalMinutes);
|
||
const backfill = Number(obj.backfillDays);
|
||
|
||
return {
|
||
accountName: asString(obj.accountName),
|
||
accountSubdomain: asString(obj.accountSubdomain),
|
||
incidentCursor: asString(obj.incidentCursor),
|
||
pollIntervalMinutes: Number.isFinite(pollInterval)
|
||
? clamp(pollInterval, 1, 60)
|
||
: DEFAULT_POLL_INTERVAL_MINUTES,
|
||
backfillDays: Number.isFinite(backfill) ? clamp(backfill, 1, 30) : DEFAULT_BACKFILL_DAYS,
|
||
severityPriorityMap,
|
||
boardId: asString(obj.boardId),
|
||
categoryId: asString(obj.categoryId) ?? null,
|
||
subcategoryId: asString(obj.subcategoryId) ?? null,
|
||
fallbackClientId: asString(obj.fallbackClientId),
|
||
fallbackBoardId: asString(obj.fallbackBoardId),
|
||
autoCloseTickets: obj.autoCloseTickets === true,
|
||
closedStatusId: asString(obj.closedStatusId) ?? null,
|
||
};
|
||
}
|
||
|
||
export function isRoutingConfigComplete(settings: HuntressSettings): boolean {
|
||
return Boolean(
|
||
settings.boardId &&
|
||
settings.fallbackClientId &&
|
||
settings.fallbackBoardId &&
|
||
settings.severityPriorityMap.critical &&
|
||
settings.severityPriorityMap.high &&
|
||
settings.severityPriorityMap.low
|
||
);
|
||
}
|
||
|
||
/** Name preference order per Huntress severity, lowercased. */
|
||
const SEVERITY_NAME_PREFERENCES: Record<keyof HuntressSeverityPriorityMap, string[]> = {
|
||
critical: ['critical', 'urgent'],
|
||
high: ['high'],
|
||
low: ['medium', 'low'],
|
||
};
|
||
|
||
export function prefillSeverityPriorityMap(
|
||
priorities: Array<{ priority_id: string; priority_name: string }>
|
||
): HuntressSeverityPriorityMap {
|
||
const byName = new Map<string, string>();
|
||
for (const p of priorities) {
|
||
const key = p.priority_name.trim().toLowerCase();
|
||
if (!byName.has(key)) byName.set(key, p.priority_id);
|
||
}
|
||
|
||
const map: HuntressSeverityPriorityMap = {};
|
||
for (const severity of Object.keys(SEVERITY_NAME_PREFERENCES) as Array<
|
||
keyof HuntressSeverityPriorityMap
|
||
>) {
|
||
for (const name of SEVERITY_NAME_PREFERENCES[severity]) {
|
||
const id = byName.get(name);
|
||
if (id) {
|
||
map[severity] = id;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
export function isPollDue(
|
||
lastSyncAt: string | Date | null | undefined,
|
||
intervalMinutes: number,
|
||
now: Date
|
||
): boolean {
|
||
if (!lastSyncAt) return true;
|
||
const last = lastSyncAt instanceof Date ? lastSyncAt.getTime() : Date.parse(lastSyncAt);
|
||
if (!Number.isFinite(last)) return true;
|
||
return now.getTime() - last >= intervalMinutes * 60_000;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/settings.test.ts`
|
||
Expected: PASS (all tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/settings.ts ee/server/src/__tests__/unit/huntress/settings.test.ts
|
||
git commit -m "feat(huntress): settings parsing, routing validation, priority prefill"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Org name matching (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/organizations/nameMatch.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/nameMatch.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/nameMatch.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
normalizeOrgName,
|
||
findExactNameMatch,
|
||
} from '@ee/lib/integrations/huntress/organizations/nameMatch';
|
||
|
||
describe('normalizeOrgName', () => {
|
||
it('lowercases, strips punctuation, collapses whitespace', () => {
|
||
expect(normalizeOrgName(' Acme, Inc. ')).toBe('acme inc');
|
||
expect(normalizeOrgName('ACME-INC')).toBe('acme inc');
|
||
expect(normalizeOrgName("O'Brien & Sons LLC")).toBe('obrien sons llc');
|
||
});
|
||
|
||
it('returns empty string for empty/whitespace input', () => {
|
||
expect(normalizeOrgName('')).toBe('');
|
||
expect(normalizeOrgName(' ')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('findExactNameMatch', () => {
|
||
const clients = [
|
||
{ client_id: 'c1', client_name: 'Acme, Inc.' },
|
||
{ client_id: 'c2', client_name: 'Globex' },
|
||
{ client_id: 'c3', client_name: 'globex' },
|
||
];
|
||
|
||
it('returns the client_id on a unique normalized match', () => {
|
||
expect(findExactNameMatch('ACME INC', clients)).toBe('c1');
|
||
});
|
||
|
||
it('returns null when no client matches', () => {
|
||
expect(findExactNameMatch('Initech', clients)).toBeNull();
|
||
});
|
||
|
||
it('returns null when the match is ambiguous (two clients normalize identically)', () => {
|
||
expect(findExactNameMatch('Globex', clients)).toBeNull();
|
||
});
|
||
|
||
it('returns null for empty org names', () => {
|
||
expect(findExactNameMatch('', clients)).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/nameMatch.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/organizations/nameMatch.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Exact-name auto-matching between Huntress organizations and Alga clients.
|
||
* Only unambiguous, exact normalized matches auto-link; anything weaker is
|
||
* left for the user to map manually.
|
||
*/
|
||
|
||
export function normalizeOrgName(name: string): string {
|
||
return name
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\s]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
export function findExactNameMatch(
|
||
orgName: string,
|
||
clients: Array<{ client_id: string; client_name: string }>
|
||
): string | null {
|
||
const target = normalizeOrgName(orgName);
|
||
if (!target) return null;
|
||
|
||
const matches = clients.filter((c) => normalizeOrgName(c.client_name) === target);
|
||
return matches.length === 1 ? matches[0].client_id : null;
|
||
}
|
||
```
|
||
|
||
Note: `'ACME-INC'` normalizes to `'acme inc'` because punctuation becomes a space before whitespace collapses.
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/nameMatch.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/organizations/nameMatch.ts ee/server/src/__tests__/unit/huntress/nameMatch.test.ts
|
||
git commit -m "feat(huntress): org name normalization and exact auto-match"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Cursor walker (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/incidents/cursorWalk.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/cursorWalk.test.ts`
|
||
|
||
The Huntress list API has no "updated since" filter; we sort by `updated_at desc` and walk pages until rows are older than the boundary (cursor minus overlap, or the backfill window on first run).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/cursorWalk.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
import { collectIncidentsSince } from '@ee/lib/integrations/huntress/incidents/cursorWalk';
|
||
import type { HuntressIncidentReport } from '@ee/interfaces/huntress.interfaces';
|
||
|
||
function incident(id: number, updatedAt: string): HuntressIncidentReport {
|
||
return {
|
||
id,
|
||
account_id: 1,
|
||
agent_id: null,
|
||
organization_id: 1,
|
||
subject: `Incident ${id}`,
|
||
summary: null,
|
||
body: null,
|
||
severity: 'low',
|
||
status: 'sent',
|
||
platform: null,
|
||
indicator_types: [],
|
||
indicator_counts: {},
|
||
sent_at: updatedAt,
|
||
closed_at: null,
|
||
status_updated_at: null,
|
||
updated_at: updatedAt,
|
||
};
|
||
}
|
||
|
||
/** fetchPage stub serving fixed pages keyed by token (undefined = first page). */
|
||
function pagesFetcher(pages: Record<string, { incidents: HuntressIncidentReport[]; nextPageToken?: string }>) {
|
||
return vi.fn(async (pageToken?: string) => pages[pageToken ?? 'first']);
|
||
}
|
||
|
||
const NOW = new Date('2026-06-09T12:00:00Z');
|
||
|
||
describe('collectIncidentsSince', () => {
|
||
it('collects incidents newer than the cursor (minus overlap) and returns ascending', async () => {
|
||
const fetchPage = pagesFetcher({
|
||
first: {
|
||
incidents: [
|
||
incident(3, '2026-06-09T11:00:00Z'),
|
||
incident(2, '2026-06-09T10:00:00Z'),
|
||
incident(1, '2026-06-08T10:00:00Z'), // older than cursor → boundary hit
|
||
],
|
||
},
|
||
});
|
||
|
||
const result = await collectIncidentsSince(fetchPage, {
|
||
cursorIso: '2026-06-09T09:00:00Z',
|
||
backfillDays: 7,
|
||
now: NOW,
|
||
});
|
||
|
||
expect(result.map((i) => i.id)).toEqual([2, 3]);
|
||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('includes incidents inside the overlap window (cursor minus 60s) for dedup-safe reprocessing', async () => {
|
||
const fetchPage = pagesFetcher({
|
||
first: {
|
||
incidents: [incident(2, '2026-06-09T08:59:30Z'), incident(1, '2026-06-09T08:00:00Z')],
|
||
},
|
||
});
|
||
|
||
const result = await collectIncidentsSince(fetchPage, {
|
||
cursorIso: '2026-06-09T09:00:00Z',
|
||
backfillDays: 7,
|
||
now: NOW,
|
||
});
|
||
|
||
// 08:59:30 is within the 60s overlap of the 09:00 cursor.
|
||
expect(result.map((i) => i.id)).toEqual([2]);
|
||
});
|
||
|
||
it('walks multiple pages until the boundary', async () => {
|
||
const fetchPage = pagesFetcher({
|
||
first: {
|
||
incidents: [incident(4, '2026-06-09T11:00:00Z'), incident(3, '2026-06-09T10:30:00Z')],
|
||
nextPageToken: 'p2',
|
||
},
|
||
p2: {
|
||
incidents: [incident(2, '2026-06-09T10:00:00Z'), incident(1, '2026-06-01T00:00:00Z')],
|
||
},
|
||
});
|
||
|
||
const result = await collectIncidentsSince(fetchPage, {
|
||
cursorIso: '2026-06-09T09:00:00Z',
|
||
backfillDays: 7,
|
||
now: NOW,
|
||
});
|
||
|
||
expect(result.map((i) => i.id)).toEqual([2, 3, 4]);
|
||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('stops paging when there is no next token even if all rows qualified', async () => {
|
||
const fetchPage = pagesFetcher({
|
||
first: { incidents: [incident(1, '2026-06-09T11:00:00Z')] },
|
||
});
|
||
|
||
const result = await collectIncidentsSince(fetchPage, {
|
||
cursorIso: '2026-06-09T09:00:00Z',
|
||
backfillDays: 7,
|
||
now: NOW,
|
||
});
|
||
|
||
expect(result.map((i) => i.id)).toEqual([1]);
|
||
});
|
||
|
||
it('uses now - backfillDays as the boundary when there is no cursor', async () => {
|
||
const fetchPage = pagesFetcher({
|
||
first: {
|
||
incidents: [
|
||
incident(2, '2026-06-08T12:00:00Z'), // 1 day old → in window
|
||
incident(1, '2026-05-01T00:00:00Z'), // far older → out
|
||
],
|
||
},
|
||
});
|
||
|
||
const result = await collectIncidentsSince(fetchPage, {
|
||
cursorIso: null,
|
||
backfillDays: 7,
|
||
now: NOW,
|
||
});
|
||
|
||
expect(result.map((i) => i.id)).toEqual([2]);
|
||
});
|
||
|
||
it('respects maxPages as a runaway guard', async () => {
|
||
const fetchPage = vi.fn(async (token?: string) => ({
|
||
incidents: [incident(Number(token ?? 1), '2026-06-09T11:00:00Z')],
|
||
nextPageToken: String(Number(token ?? 1) + 1),
|
||
}));
|
||
|
||
const result = await collectIncidentsSince(fetchPage, {
|
||
cursorIso: '2026-06-09T09:00:00Z',
|
||
backfillDays: 7,
|
||
now: NOW,
|
||
maxPages: 3,
|
||
});
|
||
|
||
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||
expect(result).toHaveLength(3);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/cursorWalk.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/incidents/cursorWalk.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Cursor-bounded collection of Huntress incident reports.
|
||
*
|
||
* The Huntress API cannot filter by "updated since"; callers fetch pages
|
||
* sorted by updated_at DESC and this walker stops once rows fall behind the
|
||
* boundary (cursor minus a small overlap, or the backfill window on first
|
||
* run). Reprocessing inside the overlap is harmless — the processor dedups
|
||
* on external_alert_id.
|
||
*/
|
||
|
||
import type { HuntressIncidentReport } from '../../../../interfaces/huntress.interfaces';
|
||
|
||
export interface CursorWalkPage {
|
||
incidents: HuntressIncidentReport[];
|
||
nextPageToken?: string;
|
||
}
|
||
|
||
export type FetchIncidentsPage = (pageToken?: string) => Promise<CursorWalkPage>;
|
||
|
||
export interface CursorWalkOptions {
|
||
/** Last fully processed updated_at (ISO-8601), or null on first run. */
|
||
cursorIso: string | null;
|
||
backfillDays: number;
|
||
/** Injection point for tests; defaults to the current time. */
|
||
now?: Date;
|
||
overlapMs?: number;
|
||
maxPages?: number;
|
||
}
|
||
|
||
const DEFAULT_OVERLAP_MS = 60_000;
|
||
const DEFAULT_MAX_PAGES = 20;
|
||
|
||
export async function collectIncidentsSince(
|
||
fetchPage: FetchIncidentsPage,
|
||
options: CursorWalkOptions
|
||
): Promise<HuntressIncidentReport[]> {
|
||
const now = options.now ?? new Date();
|
||
const overlapMs = options.overlapMs ?? DEFAULT_OVERLAP_MS;
|
||
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
||
|
||
const boundaryMs = options.cursorIso
|
||
? Date.parse(options.cursorIso) - overlapMs
|
||
: now.getTime() - options.backfillDays * 24 * 60 * 60 * 1000;
|
||
|
||
const collected: HuntressIncidentReport[] = [];
|
||
let pageToken: string | undefined;
|
||
|
||
for (let page = 0; page < maxPages; page++) {
|
||
const result = await fetchPage(pageToken);
|
||
let hitBoundary = false;
|
||
|
||
for (const item of result.incidents) {
|
||
const updatedMs = Date.parse(item.updated_at);
|
||
if (Number.isFinite(updatedMs) && updatedMs >= boundaryMs) {
|
||
collected.push(item);
|
||
} else {
|
||
hitBoundary = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (hitBoundary || !result.nextPageToken) break;
|
||
pageToken = result.nextPageToken;
|
||
}
|
||
|
||
return collected.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/cursorWalk.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/incidents/cursorWalk.ts ee/server/src/__tests__/unit/huntress/cursorWalk.test.ts
|
||
git commit -m "feat(huntress): cursor-bounded incident page walker"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Ticket content builders (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/incidents/ticketContent.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/ticketContent.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/ticketContent.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
buildPortalUrl,
|
||
buildTicketTitle,
|
||
buildTicketBody,
|
||
buildCreationNote,
|
||
buildUpdateNote,
|
||
} from '@ee/lib/integrations/huntress/incidents/ticketContent';
|
||
import type {
|
||
HuntressAgent,
|
||
HuntressIncidentReport,
|
||
} from '@ee/interfaces/huntress.interfaces';
|
||
|
||
const incident: HuntressIncidentReport = {
|
||
id: 42,
|
||
account_id: 1,
|
||
agent_id: 7,
|
||
organization_id: 9,
|
||
subject: 'CRITICAL - Incident on SRV01 (Acme)',
|
||
summary: 'Huntress detected a malicious scheduled task.',
|
||
body: null,
|
||
severity: 'critical',
|
||
status: 'sent',
|
||
platform: 'windows',
|
||
indicator_types: ['footholds', 'process_detections'],
|
||
indicator_counts: { footholds: 1, process_detections: 2 },
|
||
remediations: {
|
||
total_count: 1,
|
||
has_more: false,
|
||
items: [
|
||
{
|
||
id: 1,
|
||
type: 'manual',
|
||
action: 'Delete File',
|
||
status: 'pending',
|
||
parameters: [{ name: 'path', description: 'c:\\bad\\task' }],
|
||
},
|
||
],
|
||
},
|
||
sent_at: '2026-06-09T10:00:00Z',
|
||
closed_at: null,
|
||
status_updated_at: '2026-06-09T10:00:00Z',
|
||
updated_at: '2026-06-09T10:00:00Z',
|
||
};
|
||
|
||
const agent: HuntressAgent = {
|
||
id: 7,
|
||
hostname: 'SRV01',
|
||
os: 'Windows Server 2022',
|
||
ipv4_address: '10.0.0.5',
|
||
external_ip: '203.0.113.9',
|
||
serial_number: 'SN-123',
|
||
last_callback_at: '2026-06-09T09:55:00Z',
|
||
};
|
||
|
||
describe('buildPortalUrl', () => {
|
||
it('uses the account subdomain when known', () => {
|
||
expect(buildPortalUrl('acme', 42)).toBe('https://acme.huntress.io/incident_reports/42');
|
||
});
|
||
|
||
it('falls back to the bare portal domain without a subdomain', () => {
|
||
expect(buildPortalUrl(undefined, 42)).toBe('https://huntress.io/incident_reports/42');
|
||
});
|
||
});
|
||
|
||
describe('buildTicketTitle', () => {
|
||
it('prefixes the Huntress subject', () => {
|
||
expect(buildTicketTitle(incident, { unmapped: false })).toBe(
|
||
'[Huntress] CRITICAL - Incident on SRV01 (Acme)'
|
||
);
|
||
});
|
||
|
||
it('adds an unmapped-org marker', () => {
|
||
expect(buildTicketTitle(incident, { unmapped: true })).toBe(
|
||
'[Huntress] [Unmapped Org] CRITICAL - Incident on SRV01 (Acme)'
|
||
);
|
||
});
|
||
|
||
it('synthesizes a title when subject is missing', () => {
|
||
expect(buildTicketTitle({ ...incident, subject: null }, { unmapped: false })).toBe(
|
||
'[Huntress] critical incident #42'
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('buildTicketBody', () => {
|
||
const url = buildPortalUrl('acme', incident.id);
|
||
|
||
it('contains severity, summary, indicators, host details, remediations, and the portal link', () => {
|
||
const body = buildTicketBody(incident, agent, url, { unmapped: false });
|
||
expect(body).toContain('**Severity:** critical');
|
||
expect(body).toContain('Huntress detected a malicious scheduled task.');
|
||
expect(body).toContain('footholds (1)');
|
||
expect(body).toContain('process_detections (2)');
|
||
expect(body).toContain('**Hostname:** SRV01');
|
||
expect(body).toContain('**Internal IP:** 10.0.0.5');
|
||
expect(body).toContain('Delete File');
|
||
expect(body).toContain('c:\\bad\\task');
|
||
expect(body).toContain('https://acme.huntress.io/incident_reports/42');
|
||
});
|
||
|
||
it('shows the organization section instead of host when no agent (e.g. M365 incidents)', () => {
|
||
const body = buildTicketBody(incident, null, url, { unmapped: false, orgName: 'Acme' });
|
||
expect(body).not.toContain('**Hostname:**');
|
||
expect(body).toContain('**Huntress Organization:** Acme');
|
||
});
|
||
|
||
it('prepends an unmapped-org warning when unmapped', () => {
|
||
const body = buildTicketBody(incident, agent, url, { unmapped: true, orgName: 'Acme' });
|
||
expect(body).toContain('not mapped to a client');
|
||
expect(body).toContain('Acme');
|
||
});
|
||
});
|
||
|
||
describe('buildCreationNote', () => {
|
||
it('records the raw incident identifiers', () => {
|
||
const note = buildCreationNote(incident);
|
||
expect(note).toContain('Incident ID: 42');
|
||
expect(note).toContain('Severity: critical');
|
||
expect(note).toContain('Status: sent');
|
||
});
|
||
});
|
||
|
||
describe('buildUpdateNote', () => {
|
||
it('describes a status transition', () => {
|
||
const note = buildUpdateNote('sent', { ...incident, status: 'closed' });
|
||
expect(note).toContain('sent');
|
||
expect(note).toContain('closed');
|
||
});
|
||
|
||
it('still produces a note when only updated_at changed', () => {
|
||
const note = buildUpdateNote('sent', incident);
|
||
expect(note).toContain('updated');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/ticketContent.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/incidents/ticketContent.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Self-contained ticket content for Huntress incidents: a tech should be able
|
||
* to triage from the ticket without opening the Huntress portal.
|
||
*/
|
||
|
||
import type {
|
||
HuntressAgent,
|
||
HuntressIncidentReport,
|
||
} from '../../../../interfaces/huntress.interfaces';
|
||
|
||
/**
|
||
* Portal deep link. The path is the standard incident-report route; the exact
|
||
* path is confirmed against a live account during smoke testing — keep all
|
||
* URL construction in this one function.
|
||
*/
|
||
export function buildPortalUrl(subdomain: string | undefined, incidentId: number): string {
|
||
const host = subdomain ? `${subdomain}.huntress.io` : 'huntress.io';
|
||
return `https://${host}/incident_reports/${incidentId}`;
|
||
}
|
||
|
||
export interface TicketTitleOptions {
|
||
unmapped: boolean;
|
||
}
|
||
|
||
export function buildTicketTitle(
|
||
incident: HuntressIncidentReport,
|
||
options: TicketTitleOptions
|
||
): string {
|
||
const prefix = options.unmapped ? '[Huntress] [Unmapped Org]' : '[Huntress]';
|
||
const subject = incident.subject || `${incident.severity} incident #${incident.id}`;
|
||
return `${prefix} ${subject}`;
|
||
}
|
||
|
||
export interface TicketBodyOptions {
|
||
unmapped: boolean;
|
||
orgName?: string;
|
||
}
|
||
|
||
export function buildTicketBody(
|
||
incident: HuntressIncidentReport,
|
||
agent: HuntressAgent | null,
|
||
portalUrl: string,
|
||
options: TicketBodyOptions
|
||
): string {
|
||
const lines: string[] = [];
|
||
|
||
if (options.unmapped) {
|
||
lines.push('> **Unmapped organization.** The Huntress organization');
|
||
lines.push(
|
||
`> "${options.orgName ?? incident.organization_id ?? 'unknown'}" is not mapped to a client.`
|
||
);
|
||
lines.push('> Map it in Settings → Integrations → Huntress, then move this ticket.');
|
||
lines.push('');
|
||
}
|
||
|
||
lines.push('## Security Incident');
|
||
lines.push('');
|
||
lines.push(`**Severity:** ${incident.severity}`);
|
||
lines.push(`**Status:** ${incident.status}`);
|
||
if (incident.platform) lines.push(`**Platform:** ${incident.platform}`);
|
||
const indicators = incident.indicator_types
|
||
.map((t) => `${t} (${incident.indicator_counts?.[t] ?? '?'})`)
|
||
.join(', ');
|
||
if (indicators) lines.push(`**Indicators:** ${indicators}`);
|
||
if (incident.sent_at) lines.push(`**Reported:** ${incident.sent_at}`);
|
||
lines.push('');
|
||
|
||
if (incident.summary) {
|
||
lines.push('## SOC Summary');
|
||
lines.push('');
|
||
lines.push(incident.summary);
|
||
lines.push('');
|
||
}
|
||
|
||
if (agent) {
|
||
lines.push('## Affected Host');
|
||
lines.push('');
|
||
if (agent.hostname) lines.push(`**Hostname:** ${agent.hostname}`);
|
||
if (agent.os) lines.push(`**OS:** ${agent.os}`);
|
||
if (agent.ipv4_address) lines.push(`**Internal IP:** ${agent.ipv4_address}`);
|
||
if (agent.external_ip) lines.push(`**External IP:** ${agent.external_ip}`);
|
||
if (agent.serial_number) lines.push(`**Serial Number:** ${agent.serial_number}`);
|
||
if (agent.last_callback_at) lines.push(`**Last Callback:** ${agent.last_callback_at}`);
|
||
lines.push('');
|
||
} else {
|
||
lines.push('## Organization');
|
||
lines.push('');
|
||
lines.push(
|
||
`**Huntress Organization:** ${options.orgName ?? incident.organization_id ?? 'unknown'}`
|
||
);
|
||
lines.push('');
|
||
}
|
||
|
||
const remediations = incident.remediations?.items ?? [];
|
||
if (remediations.length > 0) {
|
||
lines.push('## Remediations');
|
||
lines.push('');
|
||
for (const r of remediations) {
|
||
const params = (r.parameters ?? []).map((p) => p.description).join(', ');
|
||
lines.push(`- [${r.status ?? 'unknown'}] ${r.action ?? r.type}${params ? `: ${params}` : ''}`);
|
||
}
|
||
if (incident.remediations?.has_more) {
|
||
lines.push(`- …and more (${incident.remediations.total_count} total — see portal)`);
|
||
}
|
||
lines.push('');
|
||
}
|
||
|
||
lines.push('## Links');
|
||
lines.push('');
|
||
lines.push(`[View in Huntress portal](${portalUrl})`);
|
||
lines.push('');
|
||
lines.push('---');
|
||
lines.push('*This ticket was automatically created from a Huntress incident report.*');
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export function buildCreationNote(incident: HuntressIncidentReport): string {
|
||
return [
|
||
'**Ticket created automatically from a Huntress incident report**',
|
||
'',
|
||
`Incident ID: ${incident.id}`,
|
||
`Severity: ${incident.severity}`,
|
||
`Status: ${incident.status}`,
|
||
`Updated: ${incident.updated_at}`,
|
||
].join('\n');
|
||
}
|
||
|
||
export function buildUpdateNote(
|
||
previousStatus: string,
|
||
incident: HuntressIncidentReport
|
||
): string {
|
||
const lines = ['**Huntress incident updated**', ''];
|
||
if (previousStatus !== incident.status) {
|
||
lines.push(`Status: ${previousStatus} → ${incident.status}`);
|
||
} else {
|
||
lines.push(`Incident updated in Huntress (status remains ${incident.status}).`);
|
||
}
|
||
lines.push(`Updated: ${incident.updated_at}`);
|
||
if (incident.closed_at) lines.push(`Closed at: ${incident.closed_at}`);
|
||
return lines.join('\n');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/ticketContent.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/incidents/ticketContent.ts ee/server/src/__tests__/unit/huntress/ticketContent.test.ts
|
||
git commit -m "feat(huntress): self-contained ticket content builders"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Incident action planner (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/incidents/incidentPlan.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/incidentPlan.test.ts`
|
||
|
||
This pure function is the heart of the lifecycle rules from the spec ("Incident processing"). Every decision lives here so it can be tested exhaustively without a database.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/incidentPlan.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
planIncidentAction,
|
||
isClosedIncidentStatus,
|
||
} from '@ee/lib/integrations/huntress/incidents/incidentPlan';
|
||
import { parseHuntressSettings } from '@ee/lib/integrations/huntress/settings';
|
||
import type { HuntressIncidentReport } from '@ee/interfaces/huntress.interfaces';
|
||
|
||
const settings = parseHuntressSettings({
|
||
boardId: 'board-sec',
|
||
fallbackClientId: 'client-fallback',
|
||
fallbackBoardId: 'board-triage',
|
||
severityPriorityMap: { critical: 'p-crit', high: 'p-high', low: 'p-low' },
|
||
autoCloseTickets: false,
|
||
closedStatusId: 'status-closed',
|
||
});
|
||
|
||
function incident(overrides: Partial<HuntressIncidentReport> = {}): HuntressIncidentReport {
|
||
return {
|
||
id: 1,
|
||
account_id: 1,
|
||
agent_id: null,
|
||
organization_id: 10,
|
||
subject: 's',
|
||
summary: null,
|
||
body: null,
|
||
severity: 'high',
|
||
status: 'sent',
|
||
platform: null,
|
||
indicator_types: [],
|
||
indicator_counts: {},
|
||
sent_at: '2026-06-09T10:00:00Z',
|
||
closed_at: null,
|
||
status_updated_at: null,
|
||
updated_at: '2026-06-09T10:00:00Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const mappedOrg = { client_id: 'client-1', auto_create_tickets: true };
|
||
const unmappedOrg = { client_id: null, auto_create_tickets: true };
|
||
|
||
describe('isClosedIncidentStatus', () => {
|
||
it('treats closed, dismissed, partner_dismissed as closed', () => {
|
||
expect(isClosedIncidentStatus('closed')).toBe(true);
|
||
expect(isClosedIncidentStatus('dismissed')).toBe(true);
|
||
expect(isClosedIncidentStatus('partner_dismissed')).toBe(true);
|
||
expect(isClosedIncidentStatus('sent')).toBe(false);
|
||
expect(isClosedIncidentStatus('auto_remediating')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('planIncidentAction — new incidents', () => {
|
||
it('creates a ticket for an open incident in a mapped org', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident(),
|
||
existingAlert: null,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({
|
||
kind: 'create_ticket',
|
||
clientId: 'client-1',
|
||
boardId: 'board-sec',
|
||
unmapped: false,
|
||
});
|
||
});
|
||
|
||
it('routes an unmapped org to the fallback client and triage board', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident(),
|
||
existingAlert: null,
|
||
mapping: unmappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({
|
||
kind: 'create_ticket',
|
||
clientId: 'client-fallback',
|
||
boardId: 'board-triage',
|
||
unmapped: true,
|
||
});
|
||
});
|
||
|
||
it('routes a missing mapping row to fallback as well', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident(),
|
||
existingAlert: null,
|
||
mapping: null,
|
||
settings,
|
||
});
|
||
expect(action).toMatchObject({ kind: 'create_ticket', unmapped: true });
|
||
});
|
||
|
||
it('records only when the mapping row explicitly opted out (mapped or not)', () => {
|
||
const optedOutMapped = { client_id: 'client-1', auto_create_tickets: false };
|
||
const optedOutUnmapped = { client_id: null, auto_create_tickets: false };
|
||
expect(
|
||
planIncidentAction({ incident: incident(), existingAlert: null, mapping: optedOutMapped, settings })
|
||
).toEqual({ kind: 'record_only', reason: 'org_opted_out' });
|
||
expect(
|
||
planIncidentAction({ incident: incident(), existingAlert: null, mapping: optedOutUnmapped, settings })
|
||
).toEqual({ kind: 'record_only', reason: 'org_opted_out' });
|
||
});
|
||
|
||
it('records already-closed incidents without a ticket (backfill case)', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident({ status: 'closed' }),
|
||
existingAlert: null,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({ kind: 'record_only', reason: 'already_closed' });
|
||
});
|
||
|
||
it('treats auto_remediating as open', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident({ status: 'auto_remediating' }),
|
||
existingAlert: null,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toMatchObject({ kind: 'create_ticket' });
|
||
});
|
||
|
||
it('skips deleting incidents entirely', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident({ status: 'deleting' }),
|
||
existingAlert: null,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({ kind: 'skip', reason: 'deleting' });
|
||
});
|
||
});
|
||
|
||
describe('planIncidentAction — existing alerts', () => {
|
||
const alertWithTicket = {
|
||
ticket_id: 'ticket-1',
|
||
status: 'sent',
|
||
metadata: { lastProcessedUpdatedAt: '2026-06-09T10:00:00Z' },
|
||
};
|
||
|
||
it('skips when nothing changed since last processing', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident(),
|
||
existingAlert: alertWithTicket,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({ kind: 'skip', reason: 'unchanged' });
|
||
});
|
||
|
||
it('appends a note when updated_at moved forward', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident({ updated_at: '2026-06-09T11:00:00Z' }),
|
||
existingAlert: alertWithTicket,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({ kind: 'append_note', close: false, previousStatus: 'sent' });
|
||
});
|
||
|
||
it('appends a note when status changed even if updated_at did not move', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident({ status: 'closed' }),
|
||
existingAlert: alertWithTicket,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toMatchObject({ kind: 'append_note', previousStatus: 'sent' });
|
||
});
|
||
|
||
it('closes the ticket only when autoCloseTickets is on and a closed status is configured', () => {
|
||
const closing = incident({ status: 'closed', updated_at: '2026-06-09T11:00:00Z' });
|
||
const off = planIncidentAction({
|
||
incident: closing,
|
||
existingAlert: alertWithTicket,
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(off).toMatchObject({ kind: 'append_note', close: false });
|
||
|
||
const on = planIncidentAction({
|
||
incident: closing,
|
||
existingAlert: alertWithTicket,
|
||
mapping: mappedOrg,
|
||
settings: { ...settings, autoCloseTickets: true },
|
||
});
|
||
expect(on).toMatchObject({ kind: 'append_note', close: true });
|
||
|
||
const noStatus = planIncidentAction({
|
||
incident: closing,
|
||
existingAlert: alertWithTicket,
|
||
mapping: mappedOrg,
|
||
settings: { ...settings, autoCloseTickets: true, closedStatusId: null },
|
||
});
|
||
expect(noStatus).toMatchObject({ kind: 'append_note', close: false });
|
||
});
|
||
|
||
it('updates the record only for alert rows without a ticket (no retroactive tickets)', () => {
|
||
const action = planIncidentAction({
|
||
incident: incident({ updated_at: '2026-06-09T11:00:00Z' }),
|
||
existingAlert: { ticket_id: null, status: 'sent', metadata: {} },
|
||
mapping: mappedOrg,
|
||
settings,
|
||
});
|
||
expect(action).toEqual({ kind: 'record_only', reason: 'no_linked_ticket' });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/incidentPlan.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/incidents/incidentPlan.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Pure lifecycle decision for one Huntress incident. All create/note/close/
|
||
* record/skip rules live here; the processor only executes the decision.
|
||
*/
|
||
|
||
import type {
|
||
HuntressIncidentReport,
|
||
HuntressIncidentStatus,
|
||
} from '../../../../interfaces/huntress.interfaces';
|
||
import type { HuntressSettings } from '../settings';
|
||
|
||
export type IncidentAction =
|
||
| { kind: 'skip'; reason: 'deleting' | 'unchanged' }
|
||
| { kind: 'record_only'; reason: 'org_opted_out' | 'already_closed' | 'no_linked_ticket' }
|
||
| { kind: 'create_ticket'; clientId: string; boardId: string; unmapped: boolean }
|
||
| { kind: 'append_note'; close: boolean; previousStatus: string };
|
||
|
||
export interface ExistingAlertSummary {
|
||
ticket_id?: string | null;
|
||
status: string;
|
||
metadata?: unknown;
|
||
}
|
||
|
||
export interface MappingSummary {
|
||
client_id?: string | null;
|
||
auto_create_tickets?: boolean | null;
|
||
}
|
||
|
||
export interface PlanIncidentInput {
|
||
incident: HuntressIncidentReport;
|
||
existingAlert: ExistingAlertSummary | null;
|
||
mapping: MappingSummary | null;
|
||
/** Must satisfy isRoutingConfigComplete — the poller guarantees this. */
|
||
settings: HuntressSettings;
|
||
}
|
||
|
||
const CLOSED_STATUSES: HuntressIncidentStatus[] = ['closed', 'dismissed', 'partner_dismissed'];
|
||
|
||
export function isClosedIncidentStatus(status: string): boolean {
|
||
return (CLOSED_STATUSES as string[]).includes(status);
|
||
}
|
||
|
||
function lastProcessedUpdatedAt(alert: ExistingAlertSummary): string | undefined {
|
||
const metadata = alert.metadata;
|
||
if (metadata && typeof metadata === 'object') {
|
||
const value = (metadata as Record<string, unknown>).lastProcessedUpdatedAt;
|
||
if (typeof value === 'string') return value;
|
||
}
|
||
if (typeof metadata === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(metadata);
|
||
if (parsed && typeof parsed.lastProcessedUpdatedAt === 'string') {
|
||
return parsed.lastProcessedUpdatedAt;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function hasIncidentChanged(
|
||
alert: ExistingAlertSummary,
|
||
incident: HuntressIncidentReport
|
||
): boolean {
|
||
if (alert.status !== incident.status) return true;
|
||
const last = lastProcessedUpdatedAt(alert);
|
||
if (!last) return true;
|
||
return Date.parse(incident.updated_at) > Date.parse(last);
|
||
}
|
||
|
||
export function planIncidentAction(input: PlanIncidentInput): IncidentAction {
|
||
const { incident, existingAlert, mapping, settings } = input;
|
||
|
||
if (incident.status === 'deleting') {
|
||
return { kind: 'skip', reason: 'deleting' };
|
||
}
|
||
|
||
if (existingAlert) {
|
||
if (!hasIncidentChanged(existingAlert, incident)) {
|
||
return { kind: 'skip', reason: 'unchanged' };
|
||
}
|
||
if (!existingAlert.ticket_id) {
|
||
return { kind: 'record_only', reason: 'no_linked_ticket' };
|
||
}
|
||
const close =
|
||
isClosedIncidentStatus(incident.status) &&
|
||
settings.autoCloseTickets &&
|
||
Boolean(settings.closedStatusId);
|
||
return { kind: 'append_note', close, previousStatus: existingAlert.status };
|
||
}
|
||
|
||
// New incident. An explicit opt-out on the mapping row wins (mapped or not).
|
||
if (mapping && mapping.auto_create_tickets === false) {
|
||
return { kind: 'record_only', reason: 'org_opted_out' };
|
||
}
|
||
|
||
if (isClosedIncidentStatus(incident.status)) {
|
||
return { kind: 'record_only', reason: 'already_closed' };
|
||
}
|
||
|
||
if (mapping?.client_id) {
|
||
return {
|
||
kind: 'create_ticket',
|
||
clientId: mapping.client_id,
|
||
// boardId/fallback fields are guaranteed by isRoutingConfigComplete.
|
||
boardId: settings.boardId as string,
|
||
unmapped: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
kind: 'create_ticket',
|
||
clientId: settings.fallbackClientId as string,
|
||
boardId: settings.fallbackBoardId as string,
|
||
unmapped: true,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/incidentPlan.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/incidents/incidentPlan.ts ee/server/src/__tests__/unit/huntress/incidentPlan.test.ts
|
||
git commit -m "feat(huntress): pure incident lifecycle planner"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Huntress REST client (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/huntressClient.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/huntressClient.test.ts`
|
||
|
||
Basic auth (`Base64(key:secret)`), 60 req/min account budget (client throttles to ~1.1s between requests), 429 retry with backoff, `page_token` pagination. The `sleep` function is injectable so tests run instantly.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/huntressClient.test.ts`:
|
||
|
||
```typescript
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
// vi.mock factories run during module import — hoist the mocks they read.
|
||
const { axiosGetMock, axiosCreateMock } = vi.hoisted(() => {
|
||
const axiosGetMock = vi.fn();
|
||
const axiosCreateMock = vi.fn(() => ({ get: axiosGetMock }));
|
||
return { axiosGetMock, axiosCreateMock };
|
||
});
|
||
|
||
vi.mock('axios', () => {
|
||
const isAxiosError = (e: unknown) => Boolean((e as { isAxiosError?: boolean })?.isAxiosError);
|
||
return {
|
||
default: { create: axiosCreateMock, isAxiosError },
|
||
isAxiosError,
|
||
};
|
||
});
|
||
|
||
import { HuntressClient } from '@ee/lib/integrations/huntress/huntressClient';
|
||
|
||
function axios404() {
|
||
return { isAxiosError: true, response: { status: 404, headers: {} } };
|
||
}
|
||
|
||
function axios429(retryAfter?: string) {
|
||
return {
|
||
isAxiosError: true,
|
||
response: { status: 429, headers: retryAfter ? { 'retry-after': retryAfter } : {} },
|
||
};
|
||
}
|
||
|
||
describe('HuntressClient', () => {
|
||
let sleeps: number[];
|
||
let client: HuntressClient;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
sleeps = [];
|
||
client = new HuntressClient({
|
||
apiKey: 'key',
|
||
apiSecret: 'secret',
|
||
minRequestIntervalMs: 0,
|
||
sleep: async (ms: number) => {
|
||
sleeps.push(ms);
|
||
},
|
||
});
|
||
});
|
||
|
||
it('configures axios with the Basic auth header and default base URL', () => {
|
||
expect(axiosCreateMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
baseURL: 'https://api.huntress.io',
|
||
headers: expect.objectContaining({
|
||
Authorization: `Basic ${Buffer.from('key:secret').toString('base64')}`,
|
||
}),
|
||
})
|
||
);
|
||
});
|
||
|
||
it('getAccount returns the account payload directly', async () => {
|
||
axiosGetMock.mockResolvedValueOnce({ data: { id: 1, name: 'Acme', subdomain: 'acme' } });
|
||
const account = await client.getAccount();
|
||
expect(account.subdomain).toBe('acme');
|
||
expect(axiosGetMock).toHaveBeenCalledWith('/v1/account', { params: undefined });
|
||
});
|
||
|
||
it('listOrganizations follows page tokens and unwraps the organizations key', async () => {
|
||
axiosGetMock
|
||
.mockResolvedValueOnce({
|
||
data: {
|
||
organizations: [{ id: 1, name: 'A' }],
|
||
pagination: { next_page_token: 't2' },
|
||
},
|
||
})
|
||
.mockResolvedValueOnce({
|
||
data: { organizations: [{ id: 2, name: 'B' }], pagination: {} },
|
||
});
|
||
|
||
const orgs = await client.listOrganizations();
|
||
expect(orgs.map((o) => o.id)).toEqual([1, 2]);
|
||
expect(axiosGetMock).toHaveBeenCalledTimes(2);
|
||
expect(axiosGetMock.mock.calls[1][1].params).toMatchObject({ page_token: 't2' });
|
||
});
|
||
|
||
it('listIncidentReportsPage requests updated_at desc with limit 500', async () => {
|
||
axiosGetMock.mockResolvedValueOnce({
|
||
data: { incident_reports: [], pagination: {} },
|
||
});
|
||
await client.listIncidentReportsPage({ page_token: 'abc' });
|
||
expect(axiosGetMock).toHaveBeenCalledWith('/v1/incident_reports', {
|
||
params: expect.objectContaining({
|
||
limit: 500,
|
||
sort_field: 'updated_at',
|
||
sort_direction: 'desc',
|
||
page_token: 'abc',
|
||
}),
|
||
});
|
||
});
|
||
|
||
it('getAgent unwraps the agent key and returns null on 404', async () => {
|
||
axiosGetMock.mockResolvedValueOnce({ data: { agent: { id: 7, hostname: 'SRV01' } } });
|
||
expect((await client.getAgent(7))?.hostname).toBe('SRV01');
|
||
|
||
axiosGetMock.mockRejectedValueOnce(axios404());
|
||
expect(await client.getAgent(8)).toBeNull();
|
||
});
|
||
|
||
it('getOrganization unwraps the organization key and returns null on 404', async () => {
|
||
axiosGetMock.mockResolvedValueOnce({ data: { organization: { id: 9, name: 'Acme' } } });
|
||
expect((await client.getOrganization(9))?.name).toBe('Acme');
|
||
|
||
axiosGetMock.mockRejectedValueOnce(axios404());
|
||
expect(await client.getOrganization(10)).toBeNull();
|
||
});
|
||
|
||
it('retries 429 responses using Retry-After, then succeeds', async () => {
|
||
axiosGetMock
|
||
.mockRejectedValueOnce(axios429('3'))
|
||
.mockResolvedValueOnce({ data: { id: 1, name: 'Acme', subdomain: 'acme' } });
|
||
|
||
const account = await client.getAccount();
|
||
expect(account.id).toBe(1);
|
||
expect(sleeps).toContain(3000);
|
||
});
|
||
|
||
it('gives up after exhausting 429 retries', async () => {
|
||
axiosGetMock
|
||
.mockRejectedValueOnce(axios429())
|
||
.mockRejectedValueOnce(axios429())
|
||
.mockRejectedValueOnce(axios429());
|
||
|
||
await expect(client.getAccount()).rejects.toMatchObject({
|
||
response: { status: 429 },
|
||
});
|
||
});
|
||
|
||
it('throttles consecutive requests to the configured minimum interval', async () => {
|
||
const throttled = new HuntressClient({
|
||
apiKey: 'key',
|
||
apiSecret: 'secret',
|
||
minRequestIntervalMs: 1000,
|
||
sleep: async (ms: number) => {
|
||
sleeps.push(ms);
|
||
},
|
||
});
|
||
axiosGetMock.mockResolvedValue({ data: { id: 1, name: 'a', subdomain: 's' } });
|
||
|
||
await throttled.getAccount();
|
||
await throttled.getAccount();
|
||
|
||
// Second call must have waited most of the interval.
|
||
expect(Math.max(0, ...sleeps)).toBeGreaterThan(500);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/huntressClient.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/huntressClient.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Huntress public API client.
|
||
*
|
||
* Auth: HTTP Basic — Base64(api_key:api_secret), account-level keys generated
|
||
* at <subdomain>.huntress.io/account/api_credentials.
|
||
* Rate limit: 60 requests/minute sliding window per account; the client
|
||
* spaces requests (default 1.1s) and retries 429s with backoff.
|
||
*/
|
||
|
||
import axios, { AxiosInstance } from 'axios';
|
||
import logger from '@alga-psa/core/logger';
|
||
import { getSecretProviderInstance } from '@alga-psa/core/secrets';
|
||
import { createTenantKnex } from '@/lib/db';
|
||
import type {
|
||
HuntressAccount,
|
||
HuntressAgent,
|
||
HuntressIncidentReportsPage,
|
||
HuntressOrganization,
|
||
HuntressOrganizationsPage,
|
||
} from '../../../interfaces/huntress.interfaces';
|
||
|
||
export const HUNTRESS_DEFAULT_BASE_URL = 'https://api.huntress.io';
|
||
export const HUNTRESS_API_KEY_SECRET = 'huntress_api_key';
|
||
export const HUNTRESS_API_SECRET_SECRET = 'huntress_api_secret';
|
||
|
||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 1_100; // ~54 req/min, under the 60/min budget
|
||
const MAX_RATE_LIMIT_RETRIES = 2;
|
||
const DEFAULT_RATE_LIMIT_BACKOFF_MS = 10_000;
|
||
|
||
function defaultSleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
export interface HuntressClientConfig {
|
||
apiKey: string;
|
||
apiSecret: string;
|
||
baseUrl?: string;
|
||
minRequestIntervalMs?: number;
|
||
/** Injectable for tests. */
|
||
sleep?: (ms: number) => Promise<void>;
|
||
}
|
||
|
||
export interface ListIncidentReportsPageParams {
|
||
page_token?: string;
|
||
limit?: number;
|
||
}
|
||
|
||
export class HuntressClient {
|
||
private http: AxiosInstance;
|
||
private minIntervalMs: number;
|
||
private lastRequestAt = 0;
|
||
private sleep: (ms: number) => Promise<void>;
|
||
|
||
constructor(config: HuntressClientConfig) {
|
||
const token = Buffer.from(`${config.apiKey}:${config.apiSecret}`).toString('base64');
|
||
this.minIntervalMs = config.minRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS;
|
||
this.sleep = config.sleep ?? defaultSleep;
|
||
this.http = axios.create({
|
||
baseURL: config.baseUrl || HUNTRESS_DEFAULT_BASE_URL,
|
||
timeout: DEFAULT_TIMEOUT_MS,
|
||
headers: {
|
||
Accept: 'application/json',
|
||
Authorization: `Basic ${token}`,
|
||
},
|
||
});
|
||
}
|
||
|
||
private async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
||
for (let attempt = 0; ; attempt++) {
|
||
const wait = this.lastRequestAt + this.minIntervalMs - Date.now();
|
||
if (wait > 0) await this.sleep(wait);
|
||
this.lastRequestAt = Date.now();
|
||
|
||
try {
|
||
const response = await this.http.get<T>(path, { params });
|
||
return response.data;
|
||
} catch (error) {
|
||
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
||
if (status === 429 && attempt < MAX_RATE_LIMIT_RETRIES) {
|
||
const retryAfterRaw = axios.isAxiosError(error)
|
||
? error.response?.headers?.['retry-after']
|
||
: undefined;
|
||
const retryAfter = Number(retryAfterRaw);
|
||
const backoff =
|
||
Number.isFinite(retryAfter) && retryAfter > 0
|
||
? retryAfter * 1000
|
||
: DEFAULT_RATE_LIMIT_BACKOFF_MS;
|
||
logger.warn('[HuntressClient] 429 rate limited, backing off', { path, backoff });
|
||
await this.sleep(backoff);
|
||
continue;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
private async getOrNull<T>(path: string): Promise<T | null> {
|
||
try {
|
||
return await this.get<T>(path);
|
||
} catch (error) {
|
||
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getAccount(): Promise<HuntressAccount> {
|
||
return this.get<HuntressAccount>('/v1/account');
|
||
}
|
||
|
||
async listOrganizations(): Promise<HuntressOrganization[]> {
|
||
const organizations: HuntressOrganization[] = [];
|
||
let pageToken: string | undefined;
|
||
|
||
do {
|
||
const page = await this.get<HuntressOrganizationsPage>('/v1/organizations', {
|
||
limit: 500,
|
||
...(pageToken ? { page_token: pageToken } : {}),
|
||
});
|
||
organizations.push(...(page.organizations ?? []));
|
||
pageToken = page.pagination?.next_page_token ?? undefined;
|
||
} while (pageToken);
|
||
|
||
return organizations;
|
||
}
|
||
|
||
async getOrganization(id: number): Promise<HuntressOrganization | null> {
|
||
const data = await this.getOrNull<{ organization: HuntressOrganization }>(
|
||
`/v1/organizations/${id}`
|
||
);
|
||
return data?.organization ?? null;
|
||
}
|
||
|
||
async listIncidentReportsPage(
|
||
params: ListIncidentReportsPageParams = {}
|
||
): Promise<HuntressIncidentReportsPage> {
|
||
return this.get<HuntressIncidentReportsPage>('/v1/incident_reports', {
|
||
limit: params.limit ?? 500,
|
||
sort_field: 'updated_at',
|
||
sort_direction: 'desc',
|
||
...(params.page_token ? { page_token: params.page_token } : {}),
|
||
});
|
||
}
|
||
|
||
async getAgent(id: number): Promise<HuntressAgent | null> {
|
||
const data = await this.getOrNull<{ agent: HuntressAgent }>(`/v1/agents/${id}`);
|
||
return data?.agent ?? null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build a client from tenant-scoped secrets. Returns null when credentials
|
||
* are not configured (caller surfaces the error state).
|
||
*/
|
||
export async function createHuntressClient(tenantId: string): Promise<HuntressClient | null> {
|
||
const secretProvider = await getSecretProviderInstance();
|
||
const [apiKey, apiSecret] = await Promise.all([
|
||
secretProvider.getTenantSecret(tenantId, HUNTRESS_API_KEY_SECRET),
|
||
secretProvider.getTenantSecret(tenantId, HUNTRESS_API_SECRET_SECRET),
|
||
]);
|
||
if (!apiKey || !apiSecret) return null;
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const row = await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, provider: 'huntress' })
|
||
.first('instance_url');
|
||
|
||
return new HuntressClient({
|
||
apiKey,
|
||
apiSecret,
|
||
baseUrl: row?.instance_url || undefined,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/huntressClient.test.ts`
|
||
Expected: PASS. (The test never exercises `createHuntressClient`, so the unmocked `@/lib/db` import is fine — it is only loaded, not called.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/huntressClient.ts ee/server/src/__tests__/unit/huntress/huntressClient.test.ts
|
||
git commit -m "feat(huntress): REST client with basic auth, throttle, 429 retry"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Ticket creator + incident processor (DB-backed TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/incidents/ticketCreator.ts`
|
||
- Create: `ee/server/src/lib/integrations/huntress/incidents/incidentProcessor.ts`
|
||
- Test: `ee/server/src/__tests__/integration/huntressIncidentProcessor.integration.test.ts`
|
||
|
||
These two modules are tested against a real database (the EE integration-test harness runs the actual migrations), which pins the live schema: `tickets.board_id`, `statuses.name/item_type/is_closed`, `comments.comment/comment_type`, `tenant_external_entity_mappings.tenant_id`. Both take their DB handle as a parameter (`knex`/`trx`) — no module mocks needed.
|
||
|
||
**Precondition:** dev Postgres running (`DB_PORT` defaults to 5432, `APP_ENV=test`).
|
||
|
||
- [ ] **Step 1: Write the failing integration test**
|
||
|
||
Create `ee/server/src/__tests__/integration/huntressIncidentProcessor.integration.test.ts`:
|
||
|
||
```typescript
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import type { Knex } from 'knex';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { createTestDbConnection } from '@main-test-utils/dbConfig';
|
||
import { parseHuntressSettings } from '@ee/lib/integrations/huntress/settings';
|
||
import type { HuntressIncidentReport } from '@ee/interfaces/huntress.interfaces';
|
||
import {
|
||
processIncident,
|
||
type ProcessIncidentDeps,
|
||
} from '@ee/lib/integrations/huntress/incidents/incidentProcessor';
|
||
|
||
const HOOK_TIMEOUT = 180_000;
|
||
|
||
let db: Knex;
|
||
|
||
// Fixture ids
|
||
const tenantId = uuidv4();
|
||
const userId = uuidv4();
|
||
const clientId = uuidv4();
|
||
const fallbackClientId = uuidv4();
|
||
const securityBoardId = uuidv4();
|
||
const triageBoardId = uuidv4();
|
||
const statusOpenId = uuidv4();
|
||
const statusClosedId = uuidv4();
|
||
const pCritId = uuidv4();
|
||
const pHighId = uuidv4();
|
||
const pLowId = uuidv4();
|
||
const integrationId = uuidv4();
|
||
const assetId = uuidv4();
|
||
|
||
const settings = parseHuntressSettings({
|
||
accountSubdomain: 'acme',
|
||
boardId: securityBoardId,
|
||
fallbackClientId,
|
||
fallbackBoardId: triageBoardId,
|
||
severityPriorityMap: { critical: pCritId, high: pHighId, low: pLowId },
|
||
autoCloseTickets: true,
|
||
closedStatusId: statusClosedId,
|
||
});
|
||
|
||
const integration = { integration_id: integrationId, settings };
|
||
|
||
const deps: ProcessIncidentDeps = {
|
||
getAgent: async (id) =>
|
||
id === 7
|
||
? {
|
||
id: 7,
|
||
hostname: 'SRV01',
|
||
os: 'Windows Server 2022',
|
||
ipv4_address: '10.0.0.5',
|
||
external_ip: null,
|
||
serial_number: 'SN-1',
|
||
last_callback_at: null,
|
||
}
|
||
: null,
|
||
getOrganization: async (id) => ({ id, name: `Discovered Org ${id}` }),
|
||
};
|
||
|
||
function incident(overrides: Partial<HuntressIncidentReport> = {}): HuntressIncidentReport {
|
||
return {
|
||
id: 1000,
|
||
account_id: 1,
|
||
agent_id: 7,
|
||
organization_id: 500,
|
||
subject: 'HIGH - Incident on SRV01',
|
||
summary: 'Malicious task detected.',
|
||
body: null,
|
||
severity: 'high',
|
||
status: 'sent',
|
||
platform: 'windows',
|
||
indicator_types: ['footholds'],
|
||
indicator_counts: { footholds: 1 },
|
||
sent_at: '2026-06-09T10:00:00Z',
|
||
closed_at: null,
|
||
status_updated_at: '2026-06-09T10:00:00Z',
|
||
updated_at: '2026-06-09T10:00:00Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
async function hasColumn(table: string, column: string): Promise<boolean> {
|
||
return db.schema.hasColumn(table, column);
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
process.env.DB_PORT = process.env.DB_PORT || '5432';
|
||
process.env.APP_ENV = process.env.APP_ENV || 'test';
|
||
db = await createTestDbConnection();
|
||
|
||
await db('tenants').insert({
|
||
tenant: tenantId,
|
||
...((await hasColumn('tenants', 'company_name'))
|
||
? { company_name: 'Huntress Test Tenant' }
|
||
: { client_name: 'Huntress Test Tenant' }),
|
||
email: `huntress-${tenantId.slice(0, 8)}@example.com`,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
});
|
||
|
||
await db('users').insert({
|
||
tenant: tenantId,
|
||
user_id: userId,
|
||
username: `huntress-${tenantId.slice(0, 8)}`,
|
||
hashed_password: 'not-used',
|
||
email: `huntress-user-${tenantId.slice(0, 8)}@example.com`,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
});
|
||
|
||
await db('clients').insert([
|
||
{
|
||
tenant: tenantId,
|
||
client_id: clientId,
|
||
client_name: 'Acme Corp',
|
||
is_inactive: false,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
},
|
||
{
|
||
tenant: tenantId,
|
||
client_id: fallbackClientId,
|
||
client_name: 'Internal (Unmapped Security)',
|
||
is_inactive: false,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
},
|
||
]);
|
||
|
||
await db('boards').insert([
|
||
{
|
||
tenant: tenantId,
|
||
board_id: securityBoardId,
|
||
board_name: 'Security',
|
||
is_default: false,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
},
|
||
{
|
||
tenant: tenantId,
|
||
board_id: triageBoardId,
|
||
board_name: 'Security Triage',
|
||
is_default: false,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
},
|
||
]);
|
||
|
||
const statusItemType = (await hasColumn('statuses', 'item_type'))
|
||
? { item_type: 'ticket' }
|
||
: { status_type: 'ticket' };
|
||
await db('statuses').insert([
|
||
{
|
||
tenant: tenantId,
|
||
status_id: statusOpenId,
|
||
name: 'Open',
|
||
...statusItemType,
|
||
is_closed: false,
|
||
is_default: true,
|
||
order_number: 10,
|
||
created_by: userId,
|
||
},
|
||
{
|
||
tenant: tenantId,
|
||
status_id: statusClosedId,
|
||
name: 'Closed',
|
||
...statusItemType,
|
||
is_closed: true,
|
||
is_default: false,
|
||
order_number: 20,
|
||
created_by: userId,
|
||
},
|
||
]);
|
||
|
||
await db('priorities').insert(
|
||
[
|
||
{ id: pCritId, name: 'Critical', order: 1 },
|
||
{ id: pHighId, name: 'High', order: 2 },
|
||
{ id: pLowId, name: 'Medium', order: 3 },
|
||
].map((p) => ({
|
||
tenant: tenantId,
|
||
priority_id: p.id,
|
||
priority_name: p.name,
|
||
item_type: 'ticket',
|
||
order_number: p.order,
|
||
color: '#888888',
|
||
created_by: userId,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
}))
|
||
);
|
||
|
||
await db('rmm_integrations').insert({
|
||
tenant: tenantId,
|
||
integration_id: integrationId,
|
||
provider: 'huntress',
|
||
instance_url: 'https://api.huntress.io',
|
||
is_active: true,
|
||
connected_at: db.fn.now(),
|
||
settings: JSON.stringify(settings),
|
||
});
|
||
|
||
// Org 500 is mapped to Acme; org 600 has no mapping row at all.
|
||
await db('rmm_organization_mappings').insert({
|
||
tenant: tenantId,
|
||
mapping_id: uuidv4(),
|
||
integration_id: integrationId,
|
||
external_organization_id: '500',
|
||
external_organization_name: 'Acme Corp',
|
||
client_id: clientId,
|
||
auto_sync_assets: false,
|
||
auto_create_tickets: true,
|
||
});
|
||
|
||
await db('assets').insert({
|
||
tenant: tenantId,
|
||
asset_id: assetId,
|
||
asset_type: 'workstation',
|
||
name: 'SRV01',
|
||
asset_tag: 'HT-1',
|
||
serial_number: 'SN-1',
|
||
client_id: clientId,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
});
|
||
}, HOOK_TIMEOUT);
|
||
|
||
afterAll(async () => {
|
||
if (!db) return;
|
||
for (const table of [
|
||
'comments',
|
||
'comment_threads',
|
||
'asset_associations',
|
||
'rmm_alerts',
|
||
'tickets',
|
||
'tenant_external_entity_mappings',
|
||
'rmm_organization_mappings',
|
||
'rmm_integrations',
|
||
'assets',
|
||
'priorities',
|
||
'statuses',
|
||
'boards',
|
||
'clients',
|
||
'users',
|
||
'tenants',
|
||
]) {
|
||
const tenantColumn = table === 'tenant_external_entity_mappings' ? 'tenant_id' : 'tenant';
|
||
await db(table)
|
||
.where({ [tenantColumn]: tenantId })
|
||
.del()
|
||
.catch(() => undefined);
|
||
}
|
||
await db.destroy().catch(() => undefined);
|
||
}, HOOK_TIMEOUT);
|
||
|
||
describe('processIncident (DB integration)', () => {
|
||
it('creates a routed, self-contained ticket for a new mapped incident', async () => {
|
||
const result = await processIncident(db, tenantId, integration, incident(), deps);
|
||
expect(result.ok).toBe(true);
|
||
expect(result.action).toBe('create_ticket');
|
||
|
||
const alert = await db('rmm_alerts')
|
||
.where({ tenant: tenantId, integration_id: integrationId, external_alert_id: '1000' })
|
||
.first();
|
||
expect(alert).toBeTruthy();
|
||
expect(alert.ticket_id).toBeTruthy();
|
||
expect(alert.severity).toBe('high');
|
||
expect(alert.asset_id).toBe(assetId);
|
||
|
||
const ticket = await db('tickets')
|
||
.where({ tenant: tenantId, ticket_id: alert.ticket_id })
|
||
.first();
|
||
expect(ticket.client_id).toBe(clientId);
|
||
expect(ticket.board_id).toBe(securityBoardId);
|
||
expect(ticket.priority_id).toBe(pHighId);
|
||
expect(ticket.status_id).toBe(statusOpenId);
|
||
expect(ticket.source).toBe('huntress');
|
||
expect(ticket.source_reference).toBe('1000');
|
||
expect(ticket.title).toContain('[Huntress]');
|
||
expect(ticket.attributes?.description ?? ticket.description).toContain('SRV01');
|
||
expect(ticket.attributes?.description ?? ticket.description).toContain(
|
||
'https://acme.huntress.io/incident_reports/1000'
|
||
);
|
||
|
||
const association = await db('asset_associations')
|
||
.where({ tenant: tenantId, asset_id: assetId, entity_id: alert.ticket_id })
|
||
.first();
|
||
expect(association).toBeTruthy();
|
||
|
||
const note = await db('comments')
|
||
.where({ tenant: tenantId, ticket_id: alert.ticket_id })
|
||
.first();
|
||
expect(note).toBeTruthy();
|
||
|
||
const entityMapping = await db('tenant_external_entity_mappings')
|
||
.where({ tenant_id: tenantId, integration_type: 'huntress', external_entity_id: '7' })
|
||
.first();
|
||
expect(entityMapping?.alga_entity_id).toBe(assetId);
|
||
});
|
||
|
||
it('is idempotent — reprocessing the unchanged incident creates nothing new', async () => {
|
||
const before = await db('tickets').where({ tenant: tenantId }).count('* as n').first();
|
||
const result = await processIncident(db, tenantId, integration, incident(), deps);
|
||
expect(result.ok).toBe(true);
|
||
expect(result.action).toBe('skip');
|
||
const after = await db('tickets').where({ tenant: tenantId }).count('* as n').first();
|
||
expect(Number(after?.n)).toBe(Number(before?.n));
|
||
});
|
||
|
||
it('appends a note and auto-closes when the incident closes', async () => {
|
||
const alert = await db('rmm_alerts')
|
||
.where({ tenant: tenantId, external_alert_id: '1000' })
|
||
.first();
|
||
const notesBefore = await db('comments')
|
||
.where({ tenant: tenantId, ticket_id: alert.ticket_id })
|
||
.count('* as n')
|
||
.first();
|
||
|
||
const result = await processIncident(
|
||
db,
|
||
tenantId,
|
||
integration,
|
||
incident({ status: 'closed', closed_at: '2026-06-09T12:00:00Z', updated_at: '2026-06-09T12:00:00Z' }),
|
||
deps
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
expect(result.action).toBe('append_note');
|
||
|
||
const notesAfter = await db('comments')
|
||
.where({ tenant: tenantId, ticket_id: alert.ticket_id })
|
||
.count('* as n')
|
||
.first();
|
||
expect(Number(notesAfter?.n)).toBe(Number(notesBefore?.n) + 1);
|
||
|
||
const ticket = await db('tickets')
|
||
.where({ tenant: tenantId, ticket_id: alert.ticket_id })
|
||
.first();
|
||
expect(ticket.status_id).toBe(statusClosedId);
|
||
});
|
||
|
||
it('routes an unknown org to the fallback client and discovers the mapping row', async () => {
|
||
const result = await processIncident(
|
||
db,
|
||
tenantId,
|
||
integration,
|
||
incident({ id: 2000, organization_id: 600, agent_id: null, subject: 'LOW - M365 incident' }),
|
||
deps
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
expect(result.action).toBe('create_ticket');
|
||
|
||
const alert = await db('rmm_alerts')
|
||
.where({ tenant: tenantId, external_alert_id: '2000' })
|
||
.first();
|
||
const ticket = await db('tickets')
|
||
.where({ tenant: tenantId, ticket_id: alert.ticket_id })
|
||
.first();
|
||
expect(ticket.client_id).toBe(fallbackClientId);
|
||
expect(ticket.board_id).toBe(triageBoardId);
|
||
expect(ticket.title).toContain('[Unmapped Org]');
|
||
|
||
const discovered = await db('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, integration_id: integrationId, external_organization_id: '600' })
|
||
.first();
|
||
expect(discovered).toBeTruthy();
|
||
expect(discovered.client_id).toBeNull();
|
||
expect(discovered.external_organization_name).toBe('Discovered Org 600');
|
||
});
|
||
|
||
it('records without a ticket when the mapping row opted out', async () => {
|
||
await db('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, integration_id: integrationId, external_organization_id: '600' })
|
||
.update({ auto_create_tickets: false });
|
||
|
||
const result = await processIncident(
|
||
db,
|
||
tenantId,
|
||
integration,
|
||
incident({ id: 3000, organization_id: 600, agent_id: null }),
|
||
deps
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
expect(result.action).toBe('record_only');
|
||
|
||
const alert = await db('rmm_alerts')
|
||
.where({ tenant: tenantId, external_alert_id: '3000' })
|
||
.first();
|
||
expect(alert).toBeTruthy();
|
||
expect(alert.ticket_id).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
Note: the `attributes?.description ?? description` fallback exists because the tickets schema stores the description in a column on this branch; the assertion accepts either location. Everything else asserts exact columns — if an insert column is wrong, this test fails and the implementation (not the test) gets fixed.
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/integration/huntressIncidentProcessor.integration.test.ts`
|
||
Expected: FAIL — `incidentProcessor` module not found.
|
||
|
||
- [ ] **Step 3: Write the ticket creator**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/incidents/ticketCreator.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Transactional ticket creation for Huntress incidents. Mirrors the NinjaOne
|
||
* ticket creator (ee/server/src/lib/integrations/ninjaone/alerts/ticketCreator.ts)
|
||
* but takes the caller's transaction so the alert-row update commits
|
||
* atomically with the ticket, and writes the post-rename board/category
|
||
* columns.
|
||
*/
|
||
|
||
import { Knex } from 'knex';
|
||
|
||
export interface CreateHuntressTicketParams {
|
||
clientId: string;
|
||
boardId: string;
|
||
priorityId?: string;
|
||
categoryId?: string | null;
|
||
subcategoryId?: string | null;
|
||
title: string;
|
||
body: string;
|
||
/** Internal audit note added as the first comment. */
|
||
note: string;
|
||
/** Huntress incident id, stringified. */
|
||
sourceReference: string;
|
||
assetId?: string | null;
|
||
}
|
||
|
||
export interface CreatedHuntressTicket {
|
||
ticket_id: string;
|
||
ticket_number: string;
|
||
}
|
||
|
||
export async function createHuntressTicket(
|
||
trx: Knex.Transaction,
|
||
tenantId: string,
|
||
params: CreateHuntressTicketParams
|
||
): Promise<CreatedHuntressTicket> {
|
||
const defaultStatus = await trx('statuses')
|
||
.where({ tenant: tenantId, item_type: 'ticket', is_default: true })
|
||
.first();
|
||
if (!defaultStatus) {
|
||
throw new Error('No default ticket status configured for tenant');
|
||
}
|
||
|
||
const ticketNumber = await generateTicketNumber(trx, tenantId);
|
||
const now = new Date().toISOString();
|
||
|
||
const [ticket] = await trx('tickets')
|
||
.insert({
|
||
tenant: tenantId,
|
||
ticket_number: ticketNumber,
|
||
title: params.title,
|
||
client_id: params.clientId,
|
||
status_id: defaultStatus.status_id,
|
||
priority_id: params.priorityId ?? null,
|
||
board_id: params.boardId,
|
||
category_id: params.categoryId ?? null,
|
||
subcategory_id: params.subcategoryId ?? null,
|
||
description: params.body,
|
||
source: 'huntress',
|
||
source_reference: params.sourceReference,
|
||
created_at: now,
|
||
updated_at: now,
|
||
})
|
||
.returning(['ticket_id', 'ticket_number']);
|
||
|
||
if (params.assetId) {
|
||
await trx('asset_associations').insert({
|
||
tenant: tenantId,
|
||
asset_id: params.assetId,
|
||
entity_id: ticket.ticket_id,
|
||
entity_type: 'ticket',
|
||
relationship_type: 'related',
|
||
created_by: null,
|
||
created_at: now,
|
||
});
|
||
}
|
||
|
||
await addTicketInternalNote(trx, tenantId, ticket.ticket_id, params.note);
|
||
|
||
return ticket as CreatedHuntressTicket;
|
||
}
|
||
|
||
/**
|
||
* System-authored internal note. comments.thread_id is NOT NULL, so the
|
||
* thread row is created first (same pattern as the NinjaOne creator).
|
||
*/
|
||
export async function addTicketInternalNote(
|
||
trx: Knex.Transaction,
|
||
tenantId: string,
|
||
ticketId: string,
|
||
note: string
|
||
): Promise<void> {
|
||
const now = new Date().toISOString();
|
||
const generated = await trx.raw(
|
||
'SELECT gen_random_uuid() AS comment_id, gen_random_uuid() AS thread_id'
|
||
);
|
||
const ids = generated.rows?.[0] as { comment_id: string; thread_id: string } | undefined;
|
||
if (!ids?.comment_id || !ids?.thread_id) {
|
||
throw new Error('Failed to generate comment/thread identifiers');
|
||
}
|
||
|
||
await trx('comment_threads').insert({
|
||
tenant: tenantId,
|
||
thread_id: ids.thread_id,
|
||
ticket_id: ticketId,
|
||
project_task_id: null,
|
||
root_comment_id: ids.comment_id,
|
||
is_internal: true,
|
||
reply_count: 0,
|
||
last_activity_at: now,
|
||
created_at: now,
|
||
created_by: null,
|
||
});
|
||
|
||
await trx('comments').insert({
|
||
tenant: tenantId,
|
||
comment_id: ids.comment_id,
|
||
thread_id: ids.thread_id,
|
||
ticket_id: ticketId,
|
||
user_id: null,
|
||
comment_type: 'internal_note',
|
||
comment: note,
|
||
is_internal: true,
|
||
created_at: now,
|
||
});
|
||
}
|
||
|
||
/** Max ticket_number + 1 with the tenant's configured prefix (NinjaOne pattern). */
|
||
async function generateTicketNumber(trx: Knex.Transaction, tenantId: string): Promise<string> {
|
||
const result = await trx('tickets')
|
||
.where({ tenant: tenantId })
|
||
.max('ticket_number as max_number')
|
||
.first();
|
||
|
||
let nextNumber = 1;
|
||
if (result?.max_number) {
|
||
const match = String(result.max_number).match(/(\d+)$/);
|
||
if (match) nextNumber = parseInt(match[1], 10) + 1;
|
||
}
|
||
|
||
const settings = await trx('tenant_settings')
|
||
.where({ tenant: tenantId, setting_key: 'ticket_number_prefix' })
|
||
.first();
|
||
const prefix = settings?.setting_value || 'TKT-';
|
||
|
||
return `${prefix}${String(nextNumber).padStart(6, '0')}`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the incident processor**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/incidents/incidentProcessor.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Executes the planner's decision for one incident against the database.
|
||
* Takes its Knex handle as a parameter so integration tests run it on a real
|
||
* test database with no module mocks.
|
||
*/
|
||
|
||
import { Knex } from 'knex';
|
||
import { withTransaction } from '@alga-psa/db';
|
||
import logger from '@alga-psa/core/logger';
|
||
import type {
|
||
HuntressAgent,
|
||
HuntressIncidentReport,
|
||
HuntressOrganization,
|
||
} from '../../../../interfaces/huntress.interfaces';
|
||
import type { HuntressSettings } from '../settings';
|
||
import { planIncidentAction, type IncidentAction } from './incidentPlan';
|
||
import {
|
||
buildCreationNote,
|
||
buildPortalUrl,
|
||
buildTicketBody,
|
||
buildTicketTitle,
|
||
buildUpdateNote,
|
||
} from './ticketContent';
|
||
import { addTicketInternalNote, createHuntressTicket } from './ticketCreator';
|
||
|
||
export interface ProcessIncidentDeps {
|
||
getAgent: (agentId: number) => Promise<HuntressAgent | null>;
|
||
getOrganization: (orgId: number) => Promise<HuntressOrganization | null>;
|
||
}
|
||
|
||
export interface ProcessIncidentResult {
|
||
ok: boolean;
|
||
action: IncidentAction['kind'] | 'error';
|
||
ticketId?: string;
|
||
error?: string;
|
||
}
|
||
|
||
export interface HuntressIntegrationContext {
|
||
integration_id: string;
|
||
settings: HuntressSettings;
|
||
}
|
||
|
||
export async function processIncident(
|
||
knex: Knex,
|
||
tenantId: string,
|
||
integration: HuntressIntegrationContext,
|
||
incident: HuntressIncidentReport,
|
||
deps: ProcessIncidentDeps
|
||
): Promise<ProcessIncidentResult> {
|
||
const externalAlertId = String(incident.id);
|
||
|
||
try {
|
||
const existingAlert = await knex('rmm_alerts')
|
||
.where({
|
||
tenant: tenantId,
|
||
integration_id: integration.integration_id,
|
||
external_alert_id: externalAlertId,
|
||
})
|
||
.first();
|
||
|
||
let mapping =
|
||
incident.organization_id != null
|
||
? await knex('rmm_organization_mappings')
|
||
.where({
|
||
tenant: tenantId,
|
||
integration_id: integration.integration_id,
|
||
external_organization_id: String(incident.organization_id),
|
||
})
|
||
.first()
|
||
: null;
|
||
|
||
// Org created in Huntress after the last org sync: discover it on demand
|
||
// so the mapping screen stays current, then fall through to fallback
|
||
// routing (the new row is unmapped).
|
||
if (!mapping && incident.organization_id != null) {
|
||
const org = await deps
|
||
.getOrganization(incident.organization_id)
|
||
.catch(() => null);
|
||
const [inserted] = await knex('rmm_organization_mappings')
|
||
.insert({
|
||
tenant: tenantId,
|
||
mapping_id: knex.raw('gen_random_uuid()'),
|
||
integration_id: integration.integration_id,
|
||
external_organization_id: String(incident.organization_id),
|
||
external_organization_name: org?.name ?? `Huntress org ${incident.organization_id}`,
|
||
client_id: null,
|
||
auto_sync_assets: false,
|
||
auto_create_tickets: true,
|
||
metadata: JSON.stringify({ discoveredVia: 'incident_poll' }),
|
||
})
|
||
.onConflict(['tenant', 'integration_id', 'external_organization_id'])
|
||
.ignore()
|
||
.returning('*');
|
||
mapping =
|
||
inserted ??
|
||
(await knex('rmm_organization_mappings')
|
||
.where({
|
||
tenant: tenantId,
|
||
integration_id: integration.integration_id,
|
||
external_organization_id: String(incident.organization_id),
|
||
})
|
||
.first());
|
||
}
|
||
|
||
const action = planIncidentAction({
|
||
incident,
|
||
existingAlert: existingAlert ?? null,
|
||
mapping: mapping ?? null,
|
||
settings: integration.settings,
|
||
});
|
||
|
||
if (action.kind === 'skip') {
|
||
return { ok: true, action: action.kind };
|
||
}
|
||
|
||
// Agent details and asset match are fetched outside the transaction
|
||
// (API call + read-only query).
|
||
let agent: HuntressAgent | null = null;
|
||
let matchedAssetId: string | null = null;
|
||
if (action.kind === 'create_ticket' && incident.agent_id != null) {
|
||
agent = await deps.getAgent(incident.agent_id).catch(() => null);
|
||
if (!action.unmapped && agent?.hostname) {
|
||
matchedAssetId = await matchAsset(knex, tenantId, action.clientId, agent);
|
||
}
|
||
}
|
||
|
||
const portalUrl = buildPortalUrl(integration.settings.accountSubdomain, incident.id);
|
||
const now = new Date().toISOString();
|
||
const alertColumns = {
|
||
severity: incident.severity,
|
||
status: incident.status,
|
||
message: incident.subject ?? null,
|
||
device_name: agent?.hostname ?? existingAlert?.device_name ?? null,
|
||
external_device_id: incident.agent_id != null ? String(incident.agent_id) : null,
|
||
triggered_at: incident.sent_at ?? incident.updated_at,
|
||
resolved_at: incident.closed_at,
|
||
metadata: JSON.stringify({
|
||
summary: incident.summary,
|
||
platform: incident.platform,
|
||
indicatorTypes: incident.indicator_types,
|
||
indicatorCounts: incident.indicator_counts,
|
||
organizationId: incident.organization_id,
|
||
portalUrl,
|
||
statusUpdatedAt: incident.status_updated_at,
|
||
lastProcessedUpdatedAt: incident.updated_at,
|
||
}),
|
||
updated_at: now,
|
||
};
|
||
|
||
const ticketId = await withTransaction(knex, async (trx: Knex.Transaction) => {
|
||
let alertId: string;
|
||
if (existingAlert) {
|
||
alertId = existingAlert.alert_id;
|
||
await trx('rmm_alerts')
|
||
.where({ tenant: tenantId, alert_id: alertId })
|
||
.update(alertColumns);
|
||
} else {
|
||
const [inserted] = await trx('rmm_alerts')
|
||
.insert({
|
||
tenant: tenantId,
|
||
integration_id: integration.integration_id,
|
||
external_alert_id: externalAlertId,
|
||
asset_id: matchedAssetId,
|
||
...alertColumns,
|
||
})
|
||
.returning('alert_id');
|
||
alertId = (inserted as { alert_id: string }).alert_id;
|
||
}
|
||
|
||
if (action.kind === 'create_ticket') {
|
||
const severityKey = incident.severity as keyof HuntressSettings['severityPriorityMap'];
|
||
const ticket = await createHuntressTicket(trx, tenantId, {
|
||
clientId: action.clientId,
|
||
boardId: action.boardId,
|
||
priorityId: integration.settings.severityPriorityMap[severityKey],
|
||
categoryId: action.unmapped ? null : integration.settings.categoryId,
|
||
subcategoryId: action.unmapped ? null : integration.settings.subcategoryId,
|
||
title: buildTicketTitle(incident, { unmapped: action.unmapped }),
|
||
body: buildTicketBody(incident, agent, portalUrl, {
|
||
unmapped: action.unmapped,
|
||
orgName: mapping?.external_organization_name ?? undefined,
|
||
}),
|
||
note: buildCreationNote(incident),
|
||
sourceReference: externalAlertId,
|
||
assetId: matchedAssetId,
|
||
});
|
||
|
||
await trx('rmm_alerts')
|
||
.where({ tenant: tenantId, alert_id: alertId })
|
||
.update({ ticket_id: ticket.ticket_id, asset_id: matchedAssetId });
|
||
|
||
if (matchedAssetId && agent) {
|
||
await upsertEntityMapping(trx, tenantId, incident, agent, matchedAssetId);
|
||
}
|
||
return ticket.ticket_id;
|
||
}
|
||
|
||
if (action.kind === 'append_note' && existingAlert?.ticket_id) {
|
||
await addTicketInternalNote(
|
||
trx,
|
||
tenantId,
|
||
existingAlert.ticket_id,
|
||
buildUpdateNote(action.previousStatus, incident)
|
||
);
|
||
if (action.close && integration.settings.closedStatusId) {
|
||
await trx('tickets')
|
||
.where({ tenant: tenantId, ticket_id: existingAlert.ticket_id })
|
||
.update({ status_id: integration.settings.closedStatusId, updated_at: now });
|
||
}
|
||
return existingAlert.ticket_id as string;
|
||
}
|
||
|
||
return undefined;
|
||
});
|
||
|
||
return { ok: true, action: action.kind, ticketId };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logger.error('[Huntress] Failed to process incident', {
|
||
tenantId,
|
||
incidentId: incident.id,
|
||
error: message,
|
||
});
|
||
return { ok: false, action: 'error', error: message };
|
||
}
|
||
}
|
||
|
||
/** Unique hostname match within the mapped client; serial number tie-breaks. */
|
||
async function matchAsset(
|
||
knex: Knex,
|
||
tenantId: string,
|
||
clientId: string,
|
||
agent: HuntressAgent
|
||
): Promise<string | null> {
|
||
const candidates = await knex('assets')
|
||
.where({ tenant: tenantId, client_id: clientId })
|
||
.whereRaw('LOWER(name) = ?', [String(agent.hostname).toLowerCase()])
|
||
.select('asset_id', 'serial_number');
|
||
|
||
if (candidates.length === 1) return candidates[0].asset_id;
|
||
if (candidates.length > 1 && agent.serial_number) {
|
||
const bySerial = candidates.filter((c) => c.serial_number === agent.serial_number);
|
||
if (bySerial.length === 1) return bySerial[0].asset_id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function upsertEntityMapping(
|
||
trx: Knex.Transaction,
|
||
tenantId: string,
|
||
incident: HuntressIncidentReport,
|
||
agent: HuntressAgent,
|
||
assetId: string
|
||
): Promise<void> {
|
||
const existing = await trx('tenant_external_entity_mappings')
|
||
.where({
|
||
tenant_id: tenantId,
|
||
integration_type: 'huntress',
|
||
external_entity_id: String(agent.id),
|
||
})
|
||
.first();
|
||
if (existing) {
|
||
await trx('tenant_external_entity_mappings')
|
||
.where({ id: existing.id })
|
||
.update({
|
||
alga_entity_id: assetId,
|
||
sync_status: 'synced',
|
||
last_synced_at: trx.fn.now(),
|
||
updated_at: trx.fn.now(),
|
||
});
|
||
return;
|
||
}
|
||
await trx('tenant_external_entity_mappings').insert({
|
||
tenant_id: tenantId,
|
||
integration_type: 'huntress',
|
||
alga_entity_type: 'asset',
|
||
alga_entity_id: assetId,
|
||
external_entity_id: String(agent.id),
|
||
external_realm_id:
|
||
incident.organization_id != null ? String(incident.organization_id) : null,
|
||
sync_status: 'synced',
|
||
last_synced_at: trx.fn.now(),
|
||
metadata: JSON.stringify({ hostname: agent.hostname }),
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the integration test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/integration/huntressIncidentProcessor.integration.test.ts`
|
||
Expected: PASS (5 tests). If an insert fails on a column name, fix the implementation insert (the migrations are the source of truth), not the test assertions.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/incidents/ticketCreator.ts ee/server/src/lib/integrations/huntress/incidents/incidentProcessor.ts ee/server/src/__tests__/integration/huntressIncidentProcessor.integration.test.ts
|
||
git commit -m "feat(huntress): transactional ticket creation and incident processing"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Organization sync with auto-match (DB-backed TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/organizations/orgSync.ts`
|
||
- Test: `ee/server/src/__tests__/integration/huntressOrgSync.integration.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing integration test**
|
||
|
||
Create `ee/server/src/__tests__/integration/huntressOrgSync.integration.test.ts`:
|
||
|
||
```typescript
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import type { Knex } from 'knex';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { createTestDbConnection } from '@main-test-utils/dbConfig';
|
||
import { syncHuntressOrganizations } from '@ee/lib/integrations/huntress/organizations/orgSync';
|
||
|
||
const HOOK_TIMEOUT = 180_000;
|
||
|
||
let db: Knex;
|
||
const tenantId = uuidv4();
|
||
const integrationId = uuidv4();
|
||
const acmeClientId = uuidv4();
|
||
|
||
const stubClient = {
|
||
listOrganizations: async () => [
|
||
{ id: 1, name: 'Acme, Inc.' }, // exact normalized match → auto-link
|
||
{ id: 2, name: 'Globex' }, // ambiguous (two Globex clients) → stay unmapped
|
||
{ id: 3, name: 'Initech' }, // no match → stay unmapped
|
||
],
|
||
};
|
||
|
||
beforeAll(async () => {
|
||
process.env.DB_PORT = process.env.DB_PORT || '5432';
|
||
process.env.APP_ENV = process.env.APP_ENV || 'test';
|
||
db = await createTestDbConnection();
|
||
|
||
const hasCompanyName = await db.schema.hasColumn('tenants', 'company_name');
|
||
await db('tenants').insert({
|
||
tenant: tenantId,
|
||
...(hasCompanyName
|
||
? { company_name: 'OrgSync Test Tenant' }
|
||
: { client_name: 'OrgSync Test Tenant' }),
|
||
email: `orgsync-${tenantId.slice(0, 8)}@example.com`,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
});
|
||
|
||
await db('clients').insert(
|
||
[
|
||
{ id: acmeClientId, name: 'ACME Inc' },
|
||
{ id: uuidv4(), name: 'Globex' },
|
||
{ id: uuidv4(), name: 'globex' },
|
||
].map((c) => ({
|
||
tenant: tenantId,
|
||
client_id: c.id,
|
||
client_name: c.name,
|
||
is_inactive: false,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
}))
|
||
);
|
||
|
||
await db('rmm_integrations').insert({
|
||
tenant: tenantId,
|
||
integration_id: integrationId,
|
||
provider: 'huntress',
|
||
is_active: true,
|
||
settings: JSON.stringify({}),
|
||
});
|
||
}, HOOK_TIMEOUT);
|
||
|
||
afterAll(async () => {
|
||
if (!db) return;
|
||
for (const table of ['rmm_organization_mappings', 'rmm_integrations', 'clients', 'tenants']) {
|
||
await db(table).where({ tenant: tenantId }).del().catch(() => undefined);
|
||
}
|
||
await db.destroy().catch(() => undefined);
|
||
}, HOOK_TIMEOUT);
|
||
|
||
describe('syncHuntressOrganizations (DB integration)', () => {
|
||
it('creates mapping rows and auto-links exact normalized name matches', async () => {
|
||
const result = await syncHuntressOrganizations(db, tenantId, integrationId, stubClient);
|
||
expect(result.created).toBe(3);
|
||
expect(result.autoMatched).toBe(1);
|
||
|
||
const rows = await db('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.orderBy('external_organization_id');
|
||
|
||
expect(rows).toHaveLength(3);
|
||
|
||
const acme = rows.find((r: any) => r.external_organization_id === '1');
|
||
expect(acme.client_id).toBe(acmeClientId);
|
||
const acmeMeta = typeof acme.metadata === 'string' ? JSON.parse(acme.metadata) : acme.metadata;
|
||
expect(acmeMeta.auto_matched).toBe(true);
|
||
|
||
const globex = rows.find((r: any) => r.external_organization_id === '2');
|
||
expect(globex.client_id).toBeNull();
|
||
|
||
const initech = rows.find((r: any) => r.external_organization_id === '3');
|
||
expect(initech.client_id).toBeNull();
|
||
expect(initech.auto_create_tickets).toBe(true);
|
||
});
|
||
|
||
it('updates names on re-sync without touching manual mappings', async () => {
|
||
// Simulate a manual mapping the user made, plus a renamed org in Huntress.
|
||
const manualClient = uuidv4();
|
||
await db('clients').insert({
|
||
tenant: tenantId,
|
||
client_id: manualClient,
|
||
client_name: 'Manually Mapped',
|
||
is_inactive: false,
|
||
created_at: db.fn.now(),
|
||
updated_at: db.fn.now(),
|
||
});
|
||
await db('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, integration_id: integrationId, external_organization_id: '3' })
|
||
.update({ client_id: manualClient });
|
||
|
||
const renamingClient = {
|
||
listOrganizations: async () => [{ id: 3, name: 'Initech Renamed' }],
|
||
};
|
||
const result = await syncHuntressOrganizations(db, tenantId, integrationId, renamingClient);
|
||
expect(result.updated).toBe(1);
|
||
|
||
const row = await db('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, integration_id: integrationId, external_organization_id: '3' })
|
||
.first();
|
||
expect(row.external_organization_name).toBe('Initech Renamed');
|
||
expect(row.client_id).toBe(manualClient); // manual mapping preserved
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/integration/huntressOrgSync.integration.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/organizations/orgSync.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Huntress organization discovery: upsert one mapping row per org, refresh
|
||
* names on every sync, and auto-link only exact normalized name matches.
|
||
*/
|
||
|
||
import { Knex } from 'knex';
|
||
import logger from '@alga-psa/core/logger';
|
||
import type { HuntressOrganization } from '../../../../interfaces/huntress.interfaces';
|
||
import { findExactNameMatch } from './nameMatch';
|
||
|
||
export interface OrgSyncClient {
|
||
listOrganizations: () => Promise<HuntressOrganization[]>;
|
||
}
|
||
|
||
export interface OrgSyncResult {
|
||
total: number;
|
||
created: number;
|
||
updated: number;
|
||
autoMatched: number;
|
||
}
|
||
|
||
export async function syncHuntressOrganizations(
|
||
knex: Knex,
|
||
tenantId: string,
|
||
integrationId: string,
|
||
client: OrgSyncClient
|
||
): Promise<OrgSyncResult> {
|
||
const organizations = await client.listOrganizations();
|
||
let created = 0;
|
||
let updated = 0;
|
||
|
||
for (const org of organizations) {
|
||
const externalId = String(org.id);
|
||
const existing = await knex('rmm_organization_mappings')
|
||
.where({
|
||
tenant: tenantId,
|
||
integration_id: integrationId,
|
||
external_organization_id: externalId,
|
||
})
|
||
.first();
|
||
|
||
if (existing) {
|
||
await knex('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, mapping_id: existing.mapping_id })
|
||
.update({
|
||
external_organization_name: org.name,
|
||
last_synced_at: knex.fn.now(),
|
||
updated_at: knex.fn.now(),
|
||
});
|
||
updated += 1;
|
||
} else {
|
||
await knex('rmm_organization_mappings').insert({
|
||
tenant: tenantId,
|
||
mapping_id: knex.raw('gen_random_uuid()'),
|
||
integration_id: integrationId,
|
||
external_organization_id: externalId,
|
||
external_organization_name: org.name,
|
||
client_id: null,
|
||
auto_sync_assets: false,
|
||
auto_create_tickets: true,
|
||
last_synced_at: knex.fn.now(),
|
||
});
|
||
created += 1;
|
||
}
|
||
}
|
||
|
||
const autoMatched = await autoMatchUnmapped(knex, tenantId, integrationId);
|
||
|
||
logger.info('[Huntress] Organization sync completed', {
|
||
tenantId,
|
||
total: organizations.length,
|
||
created,
|
||
updated,
|
||
autoMatched,
|
||
});
|
||
|
||
return { total: organizations.length, created, updated, autoMatched };
|
||
}
|
||
|
||
async function autoMatchUnmapped(
|
||
knex: Knex,
|
||
tenantId: string,
|
||
integrationId: string
|
||
): Promise<number> {
|
||
const clients = await knex('clients')
|
||
.where({ tenant: tenantId, is_inactive: false })
|
||
.select('client_id', 'client_name');
|
||
|
||
const unmapped = await knex('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.whereNull('client_id')
|
||
.select('mapping_id', 'external_organization_name', 'metadata');
|
||
|
||
let matched = 0;
|
||
for (const mapping of unmapped) {
|
||
if (!mapping.external_organization_name) continue;
|
||
const clientId = findExactNameMatch(mapping.external_organization_name, clients);
|
||
if (!clientId) continue;
|
||
|
||
const existingMetadata =
|
||
typeof mapping.metadata === 'string'
|
||
? JSON.parse(mapping.metadata || '{}')
|
||
: mapping.metadata ?? {};
|
||
await knex('rmm_organization_mappings')
|
||
.where({ tenant: tenantId, mapping_id: mapping.mapping_id })
|
||
.update({
|
||
client_id: clientId,
|
||
metadata: JSON.stringify({ ...existingMetadata, auto_matched: true }),
|
||
updated_at: knex.fn.now(),
|
||
});
|
||
matched += 1;
|
||
}
|
||
return matched;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/integration/huntressOrgSync.integration.test.ts`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/organizations/orgSync.ts ee/server/src/__tests__/integration/huntressOrgSync.integration.test.ts
|
||
git commit -m "feat(huntress): organization sync with exact-name auto-match"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Incident poller (TDD)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/incidents/incidentPoller.ts`
|
||
- Test: `ee/server/src/__tests__/unit/huntress/incidentPoller.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `ee/server/src/__tests__/unit/huntress/incidentPoller.test.ts`:
|
||
|
||
```typescript
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import type { HuntressIncidentReport } from '@ee/interfaces/huntress.interfaces';
|
||
|
||
// vi.mock factories run during module import — hoist everything they read.
|
||
const { state, processIncidentMock, createKnexMock } = vi.hoisted(() => {
|
||
const state: {
|
||
integrationRow: Record<string, unknown> | undefined;
|
||
updates: Array<Record<string, unknown>>;
|
||
client: unknown;
|
||
} = { integrationRow: undefined, updates: [], client: undefined };
|
||
|
||
function createKnexMock() {
|
||
const builder: any = {
|
||
where: vi.fn(() => builder),
|
||
first: vi.fn(async () => (state.integrationRow ? { ...state.integrationRow } : undefined)),
|
||
update: vi.fn(async (vals: Record<string, unknown>) => {
|
||
state.updates.push(vals);
|
||
return 1;
|
||
}),
|
||
};
|
||
const knex: any = vi.fn(() => builder);
|
||
knex.fn = { now: () => new Date().toISOString() };
|
||
return knex;
|
||
}
|
||
|
||
return { state, processIncidentMock: vi.fn(), createKnexMock };
|
||
});
|
||
|
||
vi.mock('@/lib/db', () => ({
|
||
createTenantKnex: vi.fn(async () => ({ knex: createKnexMock(), tenant: 'tenant-1' })),
|
||
}));
|
||
|
||
vi.mock('@ee/lib/integrations/huntress/incidents/incidentProcessor', () => ({
|
||
processIncident: processIncidentMock,
|
||
}));
|
||
|
||
vi.mock('@ee/lib/integrations/huntress/huntressClient', () => ({
|
||
createHuntressClient: vi.fn(async () => state.client),
|
||
}));
|
||
|
||
import { pollHuntressIncidents } from '@ee/lib/integrations/huntress/incidents/incidentPoller';
|
||
|
||
function incident(id: number, updatedAt: string): HuntressIncidentReport {
|
||
return {
|
||
id,
|
||
account_id: 1,
|
||
agent_id: null,
|
||
organization_id: 1,
|
||
subject: `i${id}`,
|
||
summary: null,
|
||
body: null,
|
||
severity: 'low',
|
||
status: 'sent',
|
||
platform: null,
|
||
indicator_types: [],
|
||
indicator_counts: {},
|
||
sent_at: updatedAt,
|
||
closed_at: null,
|
||
status_updated_at: null,
|
||
updated_at: updatedAt,
|
||
};
|
||
}
|
||
|
||
const completeSettings = {
|
||
boardId: 'b1',
|
||
fallbackClientId: 'c1',
|
||
fallbackBoardId: 'b2',
|
||
severityPriorityMap: { critical: 'p1', high: 'p2', low: 'p3' },
|
||
incidentCursor: '2026-06-09T08:00:00Z',
|
||
backfillDays: 7,
|
||
pollIntervalMinutes: 5,
|
||
};
|
||
|
||
function clientReturning(incidents: HuntressIncidentReport[]) {
|
||
return {
|
||
listIncidentReportsPage: vi.fn(async () => ({
|
||
// API returns newest first.
|
||
incident_reports: [...incidents].sort(
|
||
(a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at)
|
||
),
|
||
pagination: {},
|
||
})),
|
||
getAgent: vi.fn(),
|
||
getOrganization: vi.fn(),
|
||
};
|
||
}
|
||
|
||
describe('pollHuntressIncidents', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
state.updates = [];
|
||
state.integrationRow = {
|
||
tenant: 'tenant-1',
|
||
integration_id: 'int-1',
|
||
provider: 'huntress',
|
||
is_active: true,
|
||
settings: completeSettings,
|
||
};
|
||
state.client = clientReturning([]);
|
||
});
|
||
|
||
it('skips without polling when routing config is incomplete', async () => {
|
||
state.integrationRow = {
|
||
...state.integrationRow!,
|
||
settings: { ...completeSettings, boardId: undefined },
|
||
};
|
||
const result = await pollHuntressIncidents({ tenantId: 'tenant-1', integrationId: 'int-1' });
|
||
expect(result.skipped).toBe('routing_config_incomplete');
|
||
expect(processIncidentMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('marks the integration errored when credentials are missing', async () => {
|
||
state.client = null;
|
||
const result = await pollHuntressIncidents({ tenantId: 'tenant-1', integrationId: 'int-1' });
|
||
expect(result.success).toBe(false);
|
||
expect(state.updates.some((u) => u.sync_status === 'error')).toBe(true);
|
||
});
|
||
|
||
it('processes incidents in ascending order and advances the cursor past all of them', async () => {
|
||
state.client = clientReturning([
|
||
incident(1, '2026-06-09T09:00:00Z'),
|
||
incident(2, '2026-06-09T10:00:00Z'),
|
||
]);
|
||
processIncidentMock.mockResolvedValue({ ok: true, action: 'create_ticket' });
|
||
|
||
const result = await pollHuntressIncidents({ tenantId: 'tenant-1', integrationId: 'int-1' });
|
||
|
||
expect(result.success).toBe(true);
|
||
expect(result.processed).toBe(2);
|
||
expect(processIncidentMock.mock.calls.map((c) => c[3].id)).toEqual([1, 2]);
|
||
|
||
const finalUpdate = state.updates[state.updates.length - 1];
|
||
expect(finalUpdate.sync_status).toBe('completed');
|
||
expect(JSON.parse(String(finalUpdate.settings)).incidentCursor).toBe('2026-06-09T10:00:00Z');
|
||
});
|
||
|
||
it('stops at the first failure so the failed incident is retried next cycle', async () => {
|
||
state.client = clientReturning([
|
||
incident(1, '2026-06-09T09:00:00Z'),
|
||
incident(2, '2026-06-09T10:00:00Z'),
|
||
incident(3, '2026-06-09T11:00:00Z'),
|
||
]);
|
||
processIncidentMock
|
||
.mockResolvedValueOnce({ ok: true, action: 'create_ticket' })
|
||
.mockResolvedValueOnce({ ok: false, action: 'error', error: 'boom' })
|
||
.mockResolvedValue({ ok: true, action: 'create_ticket' });
|
||
|
||
const result = await pollHuntressIncidents({ tenantId: 'tenant-1', integrationId: 'int-1' });
|
||
|
||
expect(result.success).toBe(false);
|
||
expect(result.processed).toBe(1);
|
||
expect(processIncidentMock).toHaveBeenCalledTimes(2); // third never attempted
|
||
|
||
const finalUpdate = state.updates[state.updates.length - 1];
|
||
expect(finalUpdate.sync_status).toBe('error');
|
||
expect(finalUpdate.sync_error).toBe('boom');
|
||
// Cursor stops at the last successful incident.
|
||
expect(JSON.parse(String(finalUpdate.settings)).incidentCursor).toBe('2026-06-09T09:00:00Z');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/incidentPoller.test.ts`
|
||
Expected: FAIL — module not found.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `ee/server/src/lib/integrations/huntress/incidents/incidentPoller.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* One poll cycle for one tenant's Huntress integration: cursor-walk new
|
||
* incident activity, process each incident ascending, advance the cursor
|
||
* only past successes, and record sync status on the integration row.
|
||
*/
|
||
|
||
import logger from '@alga-psa/core/logger';
|
||
import { createTenantKnex } from '@/lib/db';
|
||
import { runRmmSyncWithTransport } from '../../rmm/sync/syncOrchestration';
|
||
import { createHuntressClient } from '../huntressClient';
|
||
import { isRoutingConfigComplete, parseHuntressSettings } from '../settings';
|
||
import { collectIncidentsSince } from './cursorWalk';
|
||
import { processIncident } from './incidentProcessor';
|
||
|
||
export interface HuntressPollInput {
|
||
tenantId: string;
|
||
integrationId: string;
|
||
trigger?: 'scheduled' | 'manual';
|
||
}
|
||
|
||
export interface HuntressPollResult {
|
||
success: boolean;
|
||
skipped?: 'integration_not_found' | 'routing_config_incomplete' | 'missing_credentials';
|
||
processed: number;
|
||
failed: number;
|
||
cursor?: string | null;
|
||
error?: string;
|
||
}
|
||
|
||
export async function pollHuntressIncidents(
|
||
input: HuntressPollInput
|
||
): Promise<HuntressPollResult> {
|
||
const { tenantId, integrationId } = input;
|
||
const { knex } = await createTenantKnex();
|
||
|
||
const row = await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, integration_id: integrationId, provider: 'huntress' })
|
||
.first();
|
||
if (!row || !row.is_active) {
|
||
return { success: false, skipped: 'integration_not_found', processed: 0, failed: 0 };
|
||
}
|
||
|
||
const settings = parseHuntressSettings(row.settings);
|
||
if (!isRoutingConfigComplete(settings)) {
|
||
// Not an error — setup is simply unfinished. The settings UI nags instead.
|
||
return { success: true, skipped: 'routing_config_incomplete', processed: 0, failed: 0 };
|
||
}
|
||
|
||
const client = await createHuntressClient(tenantId);
|
||
if (!client) {
|
||
await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.update({
|
||
sync_status: 'error',
|
||
sync_error: 'Missing Huntress API credentials',
|
||
updated_at: knex.fn.now(),
|
||
});
|
||
return {
|
||
success: false,
|
||
skipped: 'missing_credentials',
|
||
processed: 0,
|
||
failed: 0,
|
||
error: 'Missing Huntress API credentials',
|
||
};
|
||
}
|
||
|
||
await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.update({ sync_status: 'syncing', updated_at: knex.fn.now() });
|
||
|
||
let incidents;
|
||
try {
|
||
incidents = await collectIncidentsSince(
|
||
async (pageToken) => {
|
||
const page = await client.listIncidentReportsPage({ page_token: pageToken });
|
||
return {
|
||
incidents: page.incident_reports ?? [],
|
||
nextPageToken: page.pagination?.next_page_token ?? undefined,
|
||
};
|
||
},
|
||
{ cursorIso: settings.incidentCursor ?? null, backfillDays: settings.backfillDays }
|
||
);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.update({ sync_status: 'error', sync_error: message, updated_at: knex.fn.now() });
|
||
logger.error('[Huntress] Incident list failed', { tenantId, error: message });
|
||
return { success: false, processed: 0, failed: 0, error: message };
|
||
}
|
||
|
||
const deps = {
|
||
getAgent: (id: number) => client.getAgent(id),
|
||
getOrganization: (id: number) => client.getOrganization(id),
|
||
};
|
||
|
||
let processed = 0;
|
||
let cursor = settings.incidentCursor ?? null;
|
||
let failure: string | undefined;
|
||
|
||
for (const incident of incidents) {
|
||
const result = await processIncident(
|
||
knex,
|
||
tenantId,
|
||
{ integration_id: integrationId, settings },
|
||
incident,
|
||
deps
|
||
);
|
||
if (!result.ok) {
|
||
failure = result.error ?? 'Incident processing failed';
|
||
break;
|
||
}
|
||
processed += 1;
|
||
if (!cursor || Date.parse(incident.updated_at) > Date.parse(cursor)) {
|
||
cursor = incident.updated_at;
|
||
}
|
||
}
|
||
|
||
// Re-read settings before writing the cursor so config edits made while
|
||
// the poll ran are not clobbered.
|
||
const latest = await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.first('settings');
|
||
const merged = {
|
||
...parseHuntressSettings(latest?.settings ?? row.settings),
|
||
incidentCursor: cursor ?? undefined,
|
||
};
|
||
|
||
await knex('rmm_integrations')
|
||
.where({ tenant: tenantId, integration_id: integrationId })
|
||
.update({
|
||
settings: JSON.stringify(merged),
|
||
sync_status: failure ? 'error' : 'completed',
|
||
sync_error: failure ?? null,
|
||
last_incremental_sync_at: knex.fn.now(),
|
||
updated_at: knex.fn.now(),
|
||
});
|
||
|
||
logger.info('[Huntress] Poll cycle finished', {
|
||
tenantId,
|
||
integrationId,
|
||
trigger: input.trigger ?? 'scheduled',
|
||
collected: incidents.length,
|
||
processed,
|
||
failed: failure ? 1 : 0,
|
||
});
|
||
|
||
return { success: !failure, processed, failed: failure ? 1 : 0, cursor, error: failure };
|
||
}
|
||
|
||
/**
|
||
* Transport-wrapped entry point (HUNTRESS_SYNC_TRANSPORT → RMM_SYNC_TRANSPORT
|
||
* → 'direct'). No Temporal workflow exists yet — leave the transport unset or
|
||
* 'direct' until one is added.
|
||
*/
|
||
export async function runHuntressIncidentPoll(
|
||
input: HuntressPollInput
|
||
): Promise<HuntressPollResult> {
|
||
return runRmmSyncWithTransport<HuntressPollInput, HuntressPollResult>({
|
||
context: { provider: 'huntress', operation: 'incident_poll', input },
|
||
directExecutor: async (context) => pollHuntressIncidents(context.input),
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress/incidentPoller.test.ts`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/incidents/incidentPoller.ts ee/server/src/__tests__/unit/huntress/incidentPoller.test.ts
|
||
git commit -m "feat(huntress): per-integration incident poll cycle"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Poll scheduling, CE stub, app wiring, module index
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/integrations/huntress/scheduling.ts`
|
||
- Create: `ee/server/src/lib/integrations/huntress/index.ts`
|
||
- Create: `packages/ee/src/lib/integrations/huntress/scheduling.ts` (CE stub)
|
||
- Modify: `server/src/lib/initializeApp.ts` (end of the job-registration function, ~line 600)
|
||
|
||
pg-boss "recurring" jobs in this codebase are delayed one-shots with singleton dedup; the handler re-enqueues itself in `finally` (see the `createNextTimePeriods` handler in `server/src/lib/initializeApp.ts:478-584`). A single system-level dispatcher job ticks every 5 minutes and polls each due integration — connect/disconnect need no scheduling lifecycle of their own. The due-check (`isPollDue`) is already unit-tested in Task 2; the rest is glue verified by typecheck.
|
||
|
||
- [ ] **Step 1: Create `ee/server/src/lib/integrations/huntress/scheduling.ts`**
|
||
|
||
```typescript
|
||
/**
|
||
* Huntress poll dispatcher: a single recurring pg-boss job that iterates all
|
||
* active Huntress integrations and polls the ones whose per-tenant interval
|
||
* has elapsed. Registered from initializeApp in enterprise builds.
|
||
*/
|
||
|
||
import logger from '@alga-psa/core/logger';
|
||
import { getAdminConnection } from '@alga-psa/db/admin';
|
||
import { runWithTenant } from '@/lib/db';
|
||
import type { IJobScheduler } from 'server/src/lib/jobs/jobScheduler';
|
||
import { isPollDue, parseHuntressSettings } from './settings';
|
||
import { runHuntressIncidentPoll } from './incidents/incidentPoller';
|
||
|
||
export const HUNTRESS_POLL_JOB_NAME = 'huntress-incident-poll-dispatch';
|
||
const DISPATCH_INTERVAL = process.env.HUNTRESS_POLL_DISPATCH_INTERVAL || '5 minutes';
|
||
|
||
export async function dispatchHuntressPolls(now: Date = new Date()): Promise<void> {
|
||
const knex = await getAdminConnection();
|
||
const integrations = await knex('rmm_integrations')
|
||
.where({ provider: 'huntress', is_active: true })
|
||
.select('tenant', 'integration_id', 'settings', 'last_incremental_sync_at');
|
||
|
||
for (const row of integrations) {
|
||
const settings = parseHuntressSettings(row.settings);
|
||
if (!isPollDue(row.last_incremental_sync_at, settings.pollIntervalMinutes, now)) continue;
|
||
|
||
try {
|
||
await runWithTenant(String(row.tenant), async () => {
|
||
await runHuntressIncidentPoll({
|
||
tenantId: String(row.tenant),
|
||
integrationId: String(row.integration_id),
|
||
trigger: 'scheduled',
|
||
});
|
||
});
|
||
} catch (error) {
|
||
// One tenant's failure must never block the others.
|
||
logger.error('[Huntress] Scheduled poll failed', { tenant: row.tenant, error });
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function registerHuntressPolling(jobScheduler: IJobScheduler): Promise<void> {
|
||
jobScheduler.registerJobHandler<{ tenantId: string }>(HUNTRESS_POLL_JOB_NAME, async () => {
|
||
try {
|
||
await dispatchHuntressPolls();
|
||
} finally {
|
||
// Re-enqueue keeps the dispatcher ticking; singletonKey dedups retries.
|
||
await jobScheduler.scheduleRecurringJob(HUNTRESS_POLL_JOB_NAME, DISPATCH_INTERVAL, {
|
||
tenantId: 'system',
|
||
});
|
||
}
|
||
});
|
||
|
||
await jobScheduler.scheduleRecurringJob(HUNTRESS_POLL_JOB_NAME, DISPATCH_INTERVAL, {
|
||
tenantId: 'system',
|
||
});
|
||
logger.info('[Huntress] Incident poll dispatcher registered');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `ee/server/src/lib/integrations/huntress/index.ts`**
|
||
|
||
```typescript
|
||
export { HuntressClient, createHuntressClient } from './huntressClient';
|
||
export { parseHuntressSettings, isRoutingConfigComplete, prefillSeverityPriorityMap, isPollDue } from './settings';
|
||
export type { HuntressSettings } from './settings';
|
||
export { syncHuntressOrganizations } from './organizations/orgSync';
|
||
export { pollHuntressIncidents, runHuntressIncidentPoll } from './incidents/incidentPoller';
|
||
export { processIncident } from './incidents/incidentProcessor';
|
||
export { registerHuntressPolling, dispatchHuntressPolls, HUNTRESS_POLL_JOB_NAME } from './scheduling';
|
||
```
|
||
|
||
- [ ] **Step 3: Create the CE stub `packages/ee/src/lib/integrations/huntress/scheduling.ts`**
|
||
|
||
```typescript
|
||
/**
|
||
* Community Edition stub. The Huntress integration is an Enterprise feature;
|
||
* the EE build aliases @enterprise to ee/server/src where the real
|
||
* implementation lives.
|
||
*/
|
||
|
||
export async function registerHuntressPolling(): Promise<void> {
|
||
// no-op in CE builds
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire into `server/src/lib/initializeApp.ts`**
|
||
|
||
Find the end of the job-registration function — the per-tenant `createNextTimePeriods` scheduling loop:
|
||
|
||
```typescript
|
||
for (const { tenant } of tenants) {
|
||
try {
|
||
await jobScheduler.scheduleRecurringJob(
|
||
'createNextTimePeriods',
|
||
'24 hours',
|
||
{ tenantId: tenant }
|
||
);
|
||
} catch (error) {
|
||
logger.error(`Failed to schedule createNextTimePeriods job for tenant ${tenant}:`, error);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Insert the Huntress block before that closing brace:
|
||
|
||
```typescript
|
||
for (const { tenant } of tenants) {
|
||
try {
|
||
await jobScheduler.scheduleRecurringJob(
|
||
'createNextTimePeriods',
|
||
'24 hours',
|
||
{ tenantId: tenant }
|
||
);
|
||
} catch (error) {
|
||
logger.error(`Failed to schedule createNextTimePeriods job for tenant ${tenant}:`, error);
|
||
}
|
||
}
|
||
|
||
// Huntress incident polling (Enterprise only). The @enterprise alias
|
||
// resolves to the CE no-op stub in community builds.
|
||
if (isEnterprise) {
|
||
try {
|
||
const { registerHuntressPolling } = await import(
|
||
'@enterprise/lib/integrations/huntress/scheduling'
|
||
);
|
||
await registerHuntressPolling(jobScheduler);
|
||
} catch (error) {
|
||
logger.error('Failed to register Huntress incident polling:', error);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
(`isEnterprise` is already imported at the top of the file from `./features`.)
|
||
|
||
- [ ] **Step 5: Typecheck both apps**
|
||
|
||
Run: `cd ee/server && npm run typecheck && cd ../../server && npx tsc --noEmit -p tsconfig.json 2>&1 | head -20`
|
||
Expected: no new errors introduced by these files (compare against a pre-change run if the baseline is not clean).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/integrations/huntress/scheduling.ts ee/server/src/lib/integrations/huntress/index.ts packages/ee/src/lib/integrations/huntress/scheduling.ts server/src/lib/initializeApp.ts
|
||
git commit -m "feat(huntress): recurring poll dispatcher wired into app startup"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Server actions
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/lib/actions/integrations/huntressActions.ts`
|
||
|
||
Thin glue over the tested modules — follows `ninjaoneActions.ts` conventions (`'use server'`, `withAuth` + tier gate HOF, `hasPermission` for writes). Tier gate is `TIER_FEATURES.INTEGRATIONS` (the integrations settings tab's feature), not `ADVANCED_ASSETS` — Huntress doesn't sync assets. No unit tests (repo precedent for action files); typecheck + the UI smoke task cover them.
|
||
|
||
- [ ] **Step 1: Create `ee/server/src/lib/actions/integrations/huntressActions.ts`**
|
||
|
||
```typescript
|
||
'use server';
|
||
|
||
/**
|
||
* Huntress integration server actions: connect, status, routing settings,
|
||
* organization mappings, and manual poll trigger.
|
||
*/
|
||
|
||
import { revalidatePath } from 'next/cache';
|
||
import logger from '@alga-psa/core/logger';
|
||
import { getSecretProviderInstance } from '@alga-psa/core/secrets';
|
||
import { withAuth, hasPermission } from '@alga-psa/auth';
|
||
import { TIER_FEATURES } from '@alga-psa/types';
|
||
import { createTenantKnex } from '@/lib/db';
|
||
import { assertTierAccess } from 'server/src/lib/tier-gating/assertTierAccess';
|
||
import {
|
||
HuntressClient,
|
||
HUNTRESS_API_KEY_SECRET,
|
||
HUNTRESS_API_SECRET_SECRET,
|
||
HUNTRESS_DEFAULT_BASE_URL,
|
||
createHuntressClient,
|
||
} from '../../integrations/huntress/huntressClient';
|
||
import {
|
||
type HuntressSettings,
|
||
type HuntressSeverityPriorityMap,
|
||
isRoutingConfigComplete,
|
||
parseHuntressSettings,
|
||
prefillSeverityPriorityMap,
|
||
} from '../../integrations/huntress/settings';
|
||
import { syncHuntressOrganizations } from '../../integrations/huntress/organizations/orgSync';
|
||
import {
|
||
pollHuntressIncidents,
|
||
type HuntressPollResult,
|
||
} from '../../integrations/huntress/incidents/incidentPoller';
|
||
import type { RmmOrganizationMapping } from '../../../interfaces/rmm.interfaces';
|
||
|
||
const SETTINGS_PATH = '/msp/settings';
|
||
|
||
function withHuntressAccess<TArgs extends unknown[], TResult>(
|
||
handler: (user: any, context: { tenant: string }, ...args: TArgs) => Promise<TResult>
|
||
) {
|
||
return withAuth(async (user, context, ...args: TArgs): Promise<TResult> => {
|
||
await assertTierAccess(TIER_FEATURES.INTEGRATIONS);
|
||
return handler(user, context as { tenant: string }, ...args);
|
||
});
|
||
}
|
||
|
||
async function requireSettingsUpdatePermission(user: unknown): Promise<void> {
|
||
const allowed = await hasPermission(user, 'settings', 'update');
|
||
if (!allowed) {
|
||
throw new Error('You do not have permission to manage integrations');
|
||
}
|
||
}
|
||
|
||
async function getIntegrationRow(knex: any, tenant: string) {
|
||
return knex('rmm_integrations').where({ tenant, provider: 'huntress' }).first();
|
||
}
|
||
|
||
export interface HuntressConnectionStatus {
|
||
is_connected: boolean;
|
||
integration_id?: string;
|
||
account_name?: string;
|
||
account_subdomain?: string;
|
||
sync_status?: string;
|
||
sync_error?: string | null;
|
||
last_poll_at?: string | null;
|
||
routing_config_complete: boolean;
|
||
settings: HuntressSettings | null;
|
||
organization_count: number;
|
||
unmapped_count: number;
|
||
open_alert_count: number;
|
||
}
|
||
|
||
export const connectHuntress = withHuntressAccess(
|
||
async (
|
||
user,
|
||
{ tenant },
|
||
input: { apiKey: string; apiSecret: string; baseUrl?: string }
|
||
): Promise<{ success: boolean; error?: string; accountName?: string }> => {
|
||
await requireSettingsUpdatePermission(user);
|
||
|
||
const apiKey = input.apiKey?.trim();
|
||
const apiSecret = input.apiSecret?.trim();
|
||
const baseUrl = input.baseUrl?.trim() || HUNTRESS_DEFAULT_BASE_URL;
|
||
if (!apiKey || !apiSecret) {
|
||
return { success: false, error: 'API key and secret are required' };
|
||
}
|
||
|
||
// Validate the credentials before storing anything.
|
||
let account;
|
||
try {
|
||
account = await new HuntressClient({ apiKey, apiSecret, baseUrl }).getAccount();
|
||
} catch (error) {
|
||
logger.warn('[Huntress] Credential validation failed', { tenant, error });
|
||
return {
|
||
success: false,
|
||
error: 'Could not authenticate with Huntress — check the API key and secret',
|
||
};
|
||
}
|
||
|
||
const secretProvider = await getSecretProviderInstance();
|
||
await secretProvider.setTenantSecret(tenant, HUNTRESS_API_KEY_SECRET, apiKey);
|
||
await secretProvider.setTenantSecret(tenant, HUNTRESS_API_SECRET_SECRET, apiSecret);
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const existing = await getIntegrationRow(knex, tenant);
|
||
const existingSettings = parseHuntressSettings(existing?.settings);
|
||
|
||
// Pre-fill severity → priority by name match when not already configured.
|
||
let severityPriorityMap = existingSettings.severityPriorityMap;
|
||
if (!severityPriorityMap.critical || !severityPriorityMap.high || !severityPriorityMap.low) {
|
||
const priorities = await knex('priorities')
|
||
.where({ tenant, item_type: 'ticket' })
|
||
.select('priority_id', 'priority_name');
|
||
severityPriorityMap = { ...prefillSeverityPriorityMap(priorities), ...severityPriorityMap };
|
||
}
|
||
|
||
const settings: HuntressSettings = {
|
||
...existingSettings,
|
||
accountName: account.name,
|
||
accountSubdomain: account.subdomain,
|
||
severityPriorityMap,
|
||
};
|
||
|
||
let integrationId: string;
|
||
if (existing) {
|
||
integrationId = existing.integration_id;
|
||
await knex('rmm_integrations')
|
||
.where({ tenant, integration_id: integrationId })
|
||
.update({
|
||
instance_url: baseUrl,
|
||
is_active: true,
|
||
connected_at: knex.fn.now(),
|
||
sync_status: 'pending',
|
||
sync_error: null,
|
||
settings: JSON.stringify(settings),
|
||
updated_at: knex.fn.now(),
|
||
});
|
||
} else {
|
||
const [inserted] = await knex('rmm_integrations')
|
||
.insert({
|
||
tenant,
|
||
provider: 'huntress',
|
||
instance_url: baseUrl,
|
||
is_active: true,
|
||
connected_at: knex.fn.now(),
|
||
sync_status: 'pending',
|
||
settings: JSON.stringify(settings),
|
||
})
|
||
.returning('integration_id');
|
||
integrationId = (inserted as { integration_id: string }).integration_id;
|
||
}
|
||
|
||
// Initial org discovery is best-effort; the UI has a re-sync button.
|
||
try {
|
||
const client = new HuntressClient({ apiKey, apiSecret, baseUrl });
|
||
await syncHuntressOrganizations(knex, tenant, integrationId, client);
|
||
} catch (error) {
|
||
logger.warn('[Huntress] Initial organization sync failed', { tenant, error });
|
||
}
|
||
|
||
revalidatePath(SETTINGS_PATH);
|
||
return { success: true, accountName: account.name };
|
||
}
|
||
);
|
||
|
||
export const getHuntressConnectionStatus = withHuntressAccess(
|
||
async (_user, { tenant }): Promise<HuntressConnectionStatus> => {
|
||
const { knex } = await createTenantKnex();
|
||
const row = await getIntegrationRow(knex, tenant);
|
||
|
||
if (!row || !row.is_active) {
|
||
return {
|
||
is_connected: false,
|
||
routing_config_complete: false,
|
||
settings: null,
|
||
organization_count: 0,
|
||
unmapped_count: 0,
|
||
open_alert_count: 0,
|
||
};
|
||
}
|
||
|
||
const settings = parseHuntressSettings(row.settings);
|
||
const [orgCount, unmappedCount, openAlertCount] = await Promise.all([
|
||
knex('rmm_organization_mappings')
|
||
.where({ tenant, integration_id: row.integration_id })
|
||
.count('* as n')
|
||
.first(),
|
||
knex('rmm_organization_mappings')
|
||
.where({ tenant, integration_id: row.integration_id })
|
||
.whereNull('client_id')
|
||
.count('* as n')
|
||
.first(),
|
||
knex('rmm_alerts')
|
||
.where({ tenant, integration_id: row.integration_id })
|
||
.whereIn('status', ['sent', 'auto_remediating'])
|
||
.count('* as n')
|
||
.first(),
|
||
]);
|
||
|
||
return {
|
||
is_connected: true,
|
||
integration_id: row.integration_id,
|
||
account_name: settings.accountName,
|
||
account_subdomain: settings.accountSubdomain,
|
||
sync_status: row.sync_status,
|
||
sync_error: row.sync_error,
|
||
last_poll_at: row.last_incremental_sync_at
|
||
? new Date(row.last_incremental_sync_at).toISOString()
|
||
: null,
|
||
routing_config_complete: isRoutingConfigComplete(settings),
|
||
settings,
|
||
organization_count: Number(orgCount?.n ?? 0),
|
||
unmapped_count: Number(unmappedCount?.n ?? 0),
|
||
open_alert_count: Number(openAlertCount?.n ?? 0),
|
||
};
|
||
}
|
||
);
|
||
|
||
export interface HuntressSettingsUpdate {
|
||
boardId?: string;
|
||
categoryId?: string | null;
|
||
subcategoryId?: string | null;
|
||
fallbackClientId?: string;
|
||
fallbackBoardId?: string;
|
||
severityPriorityMap?: HuntressSeverityPriorityMap;
|
||
autoCloseTickets?: boolean;
|
||
closedStatusId?: string | null;
|
||
pollIntervalMinutes?: number;
|
||
backfillDays?: number;
|
||
}
|
||
|
||
export const updateHuntressSettings = withHuntressAccess(
|
||
async (
|
||
user,
|
||
{ tenant },
|
||
updates: HuntressSettingsUpdate
|
||
): Promise<{ success: boolean; error?: string; routing_config_complete?: boolean }> => {
|
||
await requireSettingsUpdatePermission(user);
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const row = await getIntegrationRow(knex, tenant);
|
||
if (!row) return { success: false, error: 'Huntress is not connected' };
|
||
|
||
const current = parseHuntressSettings(row.settings);
|
||
// Only routing/poll keys are user-editable; cursor and account identity
|
||
// are owned by the poller and connect flow.
|
||
const merged = parseHuntressSettings({
|
||
...current,
|
||
...(updates.boardId !== undefined ? { boardId: updates.boardId } : {}),
|
||
...(updates.categoryId !== undefined ? { categoryId: updates.categoryId } : {}),
|
||
...(updates.subcategoryId !== undefined ? { subcategoryId: updates.subcategoryId } : {}),
|
||
...(updates.fallbackClientId !== undefined
|
||
? { fallbackClientId: updates.fallbackClientId }
|
||
: {}),
|
||
...(updates.fallbackBoardId !== undefined
|
||
? { fallbackBoardId: updates.fallbackBoardId }
|
||
: {}),
|
||
...(updates.severityPriorityMap !== undefined
|
||
? { severityPriorityMap: { ...current.severityPriorityMap, ...updates.severityPriorityMap } }
|
||
: {}),
|
||
...(updates.autoCloseTickets !== undefined
|
||
? { autoCloseTickets: updates.autoCloseTickets }
|
||
: {}),
|
||
...(updates.closedStatusId !== undefined ? { closedStatusId: updates.closedStatusId } : {}),
|
||
...(updates.pollIntervalMinutes !== undefined
|
||
? { pollIntervalMinutes: updates.pollIntervalMinutes }
|
||
: {}),
|
||
...(updates.backfillDays !== undefined ? { backfillDays: updates.backfillDays } : {}),
|
||
});
|
||
merged.incidentCursor = current.incidentCursor;
|
||
|
||
await knex('rmm_integrations')
|
||
.where({ tenant, integration_id: row.integration_id })
|
||
.update({ settings: JSON.stringify(merged), updated_at: knex.fn.now() });
|
||
|
||
revalidatePath(SETTINGS_PATH);
|
||
return { success: true, routing_config_complete: isRoutingConfigComplete(merged) };
|
||
}
|
||
);
|
||
|
||
export const disconnectHuntressIntegration = withHuntressAccess(
|
||
async (user, { tenant }): Promise<{ success: boolean; error?: string }> => {
|
||
await requireSettingsUpdatePermission(user);
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const row = await getIntegrationRow(knex, tenant);
|
||
if (!row) return { success: true };
|
||
|
||
await knex('rmm_integrations')
|
||
.where({ tenant, integration_id: row.integration_id })
|
||
.update({ is_active: false, updated_at: knex.fn.now() });
|
||
|
||
const secretProvider = await getSecretProviderInstance();
|
||
await secretProvider.deleteTenantSecret(tenant, HUNTRESS_API_KEY_SECRET);
|
||
await secretProvider.deleteTenantSecret(tenant, HUNTRESS_API_SECRET_SECRET);
|
||
|
||
revalidatePath(SETTINGS_PATH);
|
||
return { success: true };
|
||
}
|
||
);
|
||
|
||
export const syncHuntressOrganizationMappings = withHuntressAccess(
|
||
async (
|
||
user,
|
||
{ tenant }
|
||
): Promise<{ success: boolean; error?: string; created?: number; autoMatched?: number }> => {
|
||
await requireSettingsUpdatePermission(user);
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const row = await getIntegrationRow(knex, tenant);
|
||
if (!row || !row.is_active) return { success: false, error: 'Huntress is not connected' };
|
||
|
||
const client = await createHuntressClient(tenant);
|
||
if (!client) return { success: false, error: 'Huntress credentials are missing' };
|
||
|
||
try {
|
||
const result = await syncHuntressOrganizations(knex, tenant, row.integration_id, client);
|
||
revalidatePath(SETTINGS_PATH);
|
||
return { success: true, created: result.created, autoMatched: result.autoMatched };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logger.error('[Huntress] Organization sync failed', { tenant, error: message });
|
||
return { success: false, error: message };
|
||
}
|
||
}
|
||
);
|
||
|
||
export const getHuntressOrganizationMappings = withHuntressAccess(
|
||
async (_user, { tenant }): Promise<RmmOrganizationMapping[]> => {
|
||
const { knex } = await createTenantKnex();
|
||
const rows = await knex('rmm_organization_mappings as rom')
|
||
.join('rmm_integrations as ri', function (this: any) {
|
||
this.on('ri.integration_id', '=', 'rom.integration_id').andOn(
|
||
'ri.tenant',
|
||
'=',
|
||
'rom.tenant'
|
||
);
|
||
})
|
||
.leftJoin('clients as c', function (this: any) {
|
||
this.on('c.client_id', '=', 'rom.client_id').andOn('c.tenant', '=', 'rom.tenant');
|
||
})
|
||
.where('rom.tenant', tenant)
|
||
.where('ri.provider', 'huntress')
|
||
.select('rom.*', 'c.client_name as company_name')
|
||
.orderBy('rom.external_organization_name', 'asc');
|
||
return rows as RmmOrganizationMapping[];
|
||
}
|
||
);
|
||
|
||
export const updateHuntressOrganizationMapping = withHuntressAccess(
|
||
async (
|
||
user,
|
||
{ tenant },
|
||
mappingId: string,
|
||
updates: { client_id?: string | null; auto_create_tickets?: boolean }
|
||
): Promise<{ success: boolean; error?: string }> => {
|
||
await requireSettingsUpdatePermission(user);
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const mapping = await knex('rmm_organization_mappings')
|
||
.where({ tenant, mapping_id: mappingId })
|
||
.first();
|
||
if (!mapping) return { success: false, error: 'Mapping not found' };
|
||
|
||
const changes: Record<string, unknown> = { updated_at: knex.fn.now() };
|
||
if (updates.client_id !== undefined) {
|
||
changes.client_id = updates.client_id;
|
||
// A manual choice supersedes any auto-match flag.
|
||
const metadata =
|
||
typeof mapping.metadata === 'string'
|
||
? JSON.parse(mapping.metadata || '{}')
|
||
: mapping.metadata ?? {};
|
||
changes.metadata = JSON.stringify({ ...metadata, auto_matched: false });
|
||
}
|
||
if (updates.auto_create_tickets !== undefined) {
|
||
changes.auto_create_tickets = updates.auto_create_tickets;
|
||
}
|
||
|
||
await knex('rmm_organization_mappings')
|
||
.where({ tenant, mapping_id: mappingId })
|
||
.update(changes);
|
||
|
||
revalidatePath(SETTINGS_PATH);
|
||
return { success: true };
|
||
}
|
||
);
|
||
|
||
export const getHuntressRoutingOptions = withHuntressAccess(
|
||
async (_user, { tenant }) => {
|
||
const { knex } = await createTenantKnex();
|
||
const [boards, priorities, categories, closedStatuses] = await Promise.all([
|
||
knex('boards').where({ tenant }).select('board_id', 'board_name').orderBy('board_name'),
|
||
knex('priorities')
|
||
.where({ tenant, item_type: 'ticket' })
|
||
.select('priority_id', 'priority_name')
|
||
.orderBy('order_number'),
|
||
knex('categories')
|
||
.where({ tenant })
|
||
.select('category_id', 'category_name', 'parent_category', 'board_id')
|
||
.orderBy('category_name'),
|
||
knex('statuses')
|
||
.where({ tenant, item_type: 'ticket', is_closed: true })
|
||
.select('status_id', 'name as status_name')
|
||
.orderBy('order_number'),
|
||
]);
|
||
return { boards, priorities, categories, closedStatuses };
|
||
}
|
||
);
|
||
|
||
export const runHuntressPollNow = withHuntressAccess(
|
||
async (user, { tenant }): Promise<HuntressPollResult & { error?: string }> => {
|
||
await requireSettingsUpdatePermission(user);
|
||
|
||
const { knex } = await createTenantKnex();
|
||
const row = await getIntegrationRow(knex, tenant);
|
||
if (!row || !row.is_active) {
|
||
return { success: false, processed: 0, failed: 0, error: 'Huntress is not connected' };
|
||
}
|
||
|
||
const result = await pollHuntressIncidents({
|
||
tenantId: tenant,
|
||
integrationId: row.integration_id,
|
||
trigger: 'manual',
|
||
});
|
||
revalidatePath(SETTINGS_PATH);
|
||
return result;
|
||
}
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `cd ee/server && npm run typecheck`
|
||
Expected: no new errors. If `withAuth`'s handler typing rejects the `(user, context, ...args)` signature, mirror the exact generic usage from `ninjaoneActions.ts:80-87`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/lib/actions/integrations/huntressActions.ts
|
||
git commit -m "feat(huntress): server actions for connect, settings, mappings, poll"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Provider registry "Security" category + setup page wiring
|
||
|
||
**Files:**
|
||
- Modify: `packages/integrations/src/lib/rmm/providerRegistry.ts`
|
||
- Modify: `packages/integrations/src/components/settings/integrations/RmmIntegrationsSetup.tsx`
|
||
- Create: `packages/ee/src/components/settings/integrations/HuntressIntegrationSettings.tsx` (CE stub)
|
||
|
||
- [ ] **Step 1: Extend the registry metadata**
|
||
|
||
In `packages/integrations/src/lib/rmm/providerRegistry.ts`:
|
||
|
||
1. Extend `RmmProviderMetadata` (lines 21-31) — change the `icon` union and add `category`:
|
||
|
||
```typescript
|
||
export interface RmmProviderMetadata {
|
||
id: RmmProvider;
|
||
title: string;
|
||
description: string;
|
||
icon: 'tacticalrmm' | 'ninjaone' | 'tanium' | 'huntress';
|
||
badge?: RmmProviderBadge;
|
||
highlights: RmmProviderHighlight[];
|
||
capabilities: RmmProviderCapabilityFlags;
|
||
requiresEnterprise: boolean;
|
||
featureFlagKey?: 'tactical-rmm-integration' | 'tanium-rmm-integration';
|
||
/** Card-grid grouping on the setup page. Defaults to 'rmm'. */
|
||
category?: 'rmm' | 'security';
|
||
}
|
||
```
|
||
|
||
2. Append the Huntress entry to `RMM_PROVIDER_REGISTRY` (after the tanium entry, before the closing `];`):
|
||
|
||
```typescript
|
||
{
|
||
id: 'huntress',
|
||
title: 'Huntress',
|
||
description:
|
||
'Managed security: SOC-reviewed incident reports become tickets automatically (Enterprise).',
|
||
icon: 'huntress',
|
||
badge: { label: 'Enterprise', variant: 'secondary' },
|
||
highlights: [
|
||
{ label: 'Ingest', value: 'SOC incidents' },
|
||
{ label: 'Cadence', value: '5-min poll' }
|
||
],
|
||
capabilities: {
|
||
connection: true,
|
||
scopeSync: true,
|
||
deviceSync: false,
|
||
events: false,
|
||
remoteActions: false
|
||
},
|
||
requiresEnterprise: true,
|
||
category: 'security'
|
||
}
|
||
```
|
||
|
||
(The existing three entries keep no `category` field — they default to `'rmm'`.)
|
||
|
||
- [ ] **Step 2: Create the CE stub component**
|
||
|
||
Create `packages/ee/src/components/settings/integrations/HuntressIntegrationSettings.tsx`:
|
||
|
||
```typescript
|
||
'use client';
|
||
|
||
import React from 'react';
|
||
|
||
/**
|
||
* Community Edition stub. Enterprise builds alias @enterprise to
|
||
* ee/server/src, replacing this with the real settings component.
|
||
*/
|
||
export default function HuntressIntegrationSettings() {
|
||
return (
|
||
<div className="rounded-lg border p-6 text-center">
|
||
<h3 className="text-base font-semibold">Huntress Integration</h3>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
The Huntress security integration is an Enterprise feature.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
(Match the surrounding NinjaOne/Tanium stubs in that directory — if they use shared Card components, mirror their imports instead.)
|
||
|
||
- [ ] **Step 3: Wire the setup page**
|
||
|
||
In `packages/integrations/src/components/settings/integrations/RmmIntegrationsSetup.tsx`:
|
||
|
||
1. Add a banner icon case inside `IntegrationBanner`'s `switch` (after the `'tanium'` case):
|
||
|
||
```typescript
|
||
case 'huntress':
|
||
return <BannerIcon className="bg-emerald-700 text-xl font-bold text-white">H</BannerIcon>;
|
||
```
|
||
|
||
2. Add a loading placeholder and dynamic import next to the Tanium ones (after the `TaniumIntegrationSettings` dynamic import):
|
||
|
||
```typescript
|
||
function HuntressLoading() {
|
||
const { t } = useTranslation('msp/integrations');
|
||
return (
|
||
<Card>
|
||
<CardContent className="py-8">
|
||
<div className="flex flex-col items-center justify-center gap-2">
|
||
<Spinner size="md" />
|
||
<span className="text-sm text-muted-foreground">
|
||
{t('integrations.rmm.huntress.loading', {
|
||
defaultValue: 'Loading Huntress integration settings...'
|
||
})}
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const HuntressIntegrationSettings = dynamic(
|
||
() => import('@enterprise/components/settings/integrations/HuntressIntegrationSettings'),
|
||
{
|
||
loading: () => <HuntressLoading />,
|
||
ssr: false
|
||
}
|
||
);
|
||
```
|
||
|
||
(Move `HuntressLoading` above the `dynamic()` call, alongside `NinjaOneLoading`/`TaniumLoading`.)
|
||
|
||
3. Register the component in `providerSettingsComponents` (line 119-123):
|
||
|
||
```typescript
|
||
const providerSettingsComponents: Partial<Record<RmmProvider, React.ComponentType>> = {
|
||
tacticalrmm: TacticalRmmIntegrationSettings,
|
||
ninjaone: NinjaOneIntegrationSettings,
|
||
tanium: TaniumIntegrationSettings,
|
||
huntress: HuntressIntegrationSettings
|
||
};
|
||
```
|
||
|
||
4. Render the card grid grouped by category. Inside the component, after `selectedOption` is computed, derive the groups:
|
||
|
||
```typescript
|
||
const rmmOptions = options.filter((o) => (o.metadata.category ?? 'rmm') === 'rmm');
|
||
const securityOptions = options.filter((o) => o.metadata.category === 'security');
|
||
```
|
||
|
||
Then replace the single grid block (the `<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2">…</div>` that maps `options`) with a per-section render. Extract the existing card markup into a local helper so it is written once:
|
||
|
||
```typescript
|
||
const renderCardGrid = (sectionOptions: RmmIntegrationOption[]) => (
|
||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2">
|
||
{sectionOptions.map((option) => {
|
||
const isSelected = option.metadata.id === selected;
|
||
return (
|
||
<Card
|
||
key={option.metadata.id}
|
||
className={[
|
||
'relative overflow-hidden transition-shadow hover:shadow-md',
|
||
isSelected ? 'ring-2 ring-[rgb(var(--color-primary-500))]' : '',
|
||
'cursor-pointer'
|
||
].join(' ')}
|
||
id={`rmm-integration-card-${option.metadata.id}`}
|
||
>
|
||
{/* ... keep the existing CardHeader / CardContent / CardFooter markup
|
||
from the current options.map() body, unchanged ... */}
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
```
|
||
|
||
and in the returned JSX:
|
||
|
||
```tsx
|
||
{rmmOptions.length > 0 && (
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||
{t('integrations.rmm.setup.rmmSection', { defaultValue: 'Remote Monitoring & Management' })}
|
||
</h3>
|
||
{renderCardGrid(rmmOptions)}
|
||
</div>
|
||
)}
|
||
|
||
{securityOptions.length > 0 && (
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||
{t('integrations.rmm.setup.securitySection', { defaultValue: 'Security' })}
|
||
</h3>
|
||
{renderCardGrid(securityOptions)}
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
The "Active Configuration" block below stays as is — `selectedOption.component` now resolves Huntress too. The CE early-return (`!isEEAvailable`) also stays: Huntress is enterprise-only and is filtered out by the registry in CE.
|
||
|
||
- [ ] **Step 4: Typecheck + lint**
|
||
|
||
Run: `cd ee/server && npm run typecheck && npm run lint -- --file ../packages/integrations/src/components/settings/integrations/RmmIntegrationsSetup.tsx 2>/dev/null || true`
|
||
Expected: typecheck clean; treat lint as advisory if the file pattern isn't supported.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/integrations/src/lib/rmm/providerRegistry.ts packages/integrations/src/components/settings/integrations/RmmIntegrationsSetup.tsx packages/ee/src/components/settings/integrations/HuntressIntegrationSettings.tsx
|
||
git commit -m "feat(huntress): provider registry entry and Security section on setup page"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Huntress settings UI (EE)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx`
|
||
|
||
Follows `NinjaOneIntegrationSettings.tsx` conventions: client component, `@alga-psa/ui` primitives, server actions from Task 12, mapping manager embedded when connected (Task 15 builds it — add its import in that task's step; until then keep the placeholder comment shown below).
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
Create `ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import React, { useCallback, useEffect, useState, useTransition } from 'react';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@alga-psa/ui/components/Card';
|
||
import { Button } from '@alga-psa/ui/components/Button';
|
||
import { Alert, AlertDescription } from '@alga-psa/ui/components/Alert';
|
||
import { Input } from '@alga-psa/ui/components/Input';
|
||
import CustomSelect from '@alga-psa/ui/components/CustomSelect';
|
||
import { ClientPicker } from '@alga-psa/ui/components/ClientPicker';
|
||
import { getAllClients } from '@alga-psa/clients/actions';
|
||
import type { IClient } from '@alga-psa/types';
|
||
import { AlertCircle, CheckCircle, RefreshCw, ShieldAlert, Unlink } from 'lucide-react';
|
||
import {
|
||
connectHuntress,
|
||
disconnectHuntressIntegration,
|
||
getHuntressConnectionStatus,
|
||
getHuntressRoutingOptions,
|
||
runHuntressPollNow,
|
||
updateHuntressSettings,
|
||
type HuntressConnectionStatus,
|
||
} from '../../../lib/actions/integrations/huntressActions';
|
||
|
||
type RoutingOptions = Awaited<ReturnType<typeof getHuntressRoutingOptions>>;
|
||
|
||
const HuntressIntegrationSettings: React.FC = () => {
|
||
const [status, setStatus] = useState<HuntressConnectionStatus | null>(null);
|
||
const [routingOptions, setRoutingOptions] = useState<RoutingOptions | null>(null);
|
||
const [clients, setClients] = useState<IClient[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [message, setMessage] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||
const [isPending, startTransition] = useTransition();
|
||
const [mappingRefreshKey, setMappingRefreshKey] = useState(0);
|
||
|
||
// Connect form
|
||
const [apiKey, setApiKey] = useState('');
|
||
const [apiSecret, setApiSecret] = useState('');
|
||
|
||
// Routing form (initialized from status.settings)
|
||
const [boardId, setBoardId] = useState<string | null>(null);
|
||
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||
const [fallbackClientId, setFallbackClientId] = useState<string | null>(null);
|
||
const [fallbackBoardId, setFallbackBoardId] = useState<string | null>(null);
|
||
const [priorityCritical, setPriorityCritical] = useState<string | null>(null);
|
||
const [priorityHigh, setPriorityHigh] = useState<string | null>(null);
|
||
const [priorityLow, setPriorityLow] = useState<string | null>(null);
|
||
const [autoClose, setAutoClose] = useState(false);
|
||
const [closedStatusId, setClosedStatusId] = useState<string | null>(null);
|
||
const [pollInterval, setPollInterval] = useState('5');
|
||
|
||
const loadAll = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [statusResult, optionsResult, clientsResult] = await Promise.all([
|
||
getHuntressConnectionStatus(),
|
||
getHuntressRoutingOptions(),
|
||
getAllClients(false),
|
||
]);
|
||
setStatus(statusResult);
|
||
setRoutingOptions(optionsResult);
|
||
setClients(clientsResult ?? []);
|
||
|
||
const s = statusResult.settings;
|
||
if (s) {
|
||
setBoardId(s.boardId ?? null);
|
||
setCategoryId(s.categoryId ?? null);
|
||
setFallbackClientId(s.fallbackClientId ?? null);
|
||
setFallbackBoardId(s.fallbackBoardId ?? null);
|
||
setPriorityCritical(s.severityPriorityMap.critical ?? null);
|
||
setPriorityHigh(s.severityPriorityMap.high ?? null);
|
||
setPriorityLow(s.severityPriorityMap.low ?? null);
|
||
setAutoClose(s.autoCloseTickets);
|
||
setClosedStatusId(s.closedStatusId ?? null);
|
||
setPollInterval(String(s.pollIntervalMinutes));
|
||
}
|
||
} catch (error) {
|
||
setMessage({ kind: 'error', text: 'Failed to load Huntress integration status' });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadAll();
|
||
}, [loadAll]);
|
||
|
||
const handleConnect = () => {
|
||
setMessage(null);
|
||
startTransition(async () => {
|
||
const result = await connectHuntress({ apiKey, apiSecret });
|
||
if (result.success) {
|
||
setApiKey('');
|
||
setApiSecret('');
|
||
setMessage({
|
||
kind: 'success',
|
||
text: `Connected to Huntress account "${result.accountName}". Complete the routing configuration below to start ticket creation.`,
|
||
});
|
||
await loadAll();
|
||
} else {
|
||
setMessage({ kind: 'error', text: result.error ?? 'Connection failed' });
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleSaveRouting = () => {
|
||
setMessage(null);
|
||
startTransition(async () => {
|
||
const result = await updateHuntressSettings({
|
||
boardId: boardId ?? undefined,
|
||
categoryId,
|
||
fallbackClientId: fallbackClientId ?? undefined,
|
||
fallbackBoardId: fallbackBoardId ?? undefined,
|
||
severityPriorityMap: {
|
||
critical: priorityCritical ?? undefined,
|
||
high: priorityHigh ?? undefined,
|
||
low: priorityLow ?? undefined,
|
||
},
|
||
autoCloseTickets: autoClose,
|
||
closedStatusId,
|
||
pollIntervalMinutes: Number(pollInterval) || 5,
|
||
});
|
||
if (result.success) {
|
||
setMessage({
|
||
kind: 'success',
|
||
text: result.routing_config_complete
|
||
? 'Routing configuration saved — incident polling is active.'
|
||
: 'Saved, but routing is still incomplete; polling stays paused until every field below is set.',
|
||
});
|
||
await loadAll();
|
||
} else {
|
||
setMessage({ kind: 'error', text: result.error ?? 'Failed to save settings' });
|
||
}
|
||
});
|
||
};
|
||
|
||
const handlePollNow = () => {
|
||
setMessage(null);
|
||
startTransition(async () => {
|
||
const result = await runHuntressPollNow();
|
||
setMessage(
|
||
result.success
|
||
? { kind: 'success', text: `Poll finished: ${result.processed} incident(s) processed.` }
|
||
: { kind: 'error', text: result.error ?? 'Poll failed' }
|
||
);
|
||
await loadAll();
|
||
});
|
||
};
|
||
|
||
const handleDisconnect = () => {
|
||
if (!window.confirm('Disconnect Huntress? Existing tickets and mappings are kept.')) return;
|
||
startTransition(async () => {
|
||
await disconnectHuntressIntegration();
|
||
setMessage({ kind: 'success', text: 'Huntress disconnected.' });
|
||
await loadAll();
|
||
});
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||
Loading Huntress integration…
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const boardOptions =
|
||
routingOptions?.boards.map((b: any) => ({ value: b.board_id, label: b.board_name })) ?? [];
|
||
const priorityOptions =
|
||
routingOptions?.priorities.map((p: any) => ({ value: p.priority_id, label: p.priority_name })) ??
|
||
[];
|
||
const categoryOptions = [
|
||
{ value: '', label: 'None' },
|
||
...(routingOptions?.categories
|
||
.filter((c: any) => !boardId || c.board_id === boardId)
|
||
.map((c: any) => ({ value: c.category_id, label: c.category_name })) ?? []),
|
||
];
|
||
const closedStatusOptions =
|
||
routingOptions?.closedStatuses.map((s: any) => ({ value: s.status_id, label: s.status_name })) ??
|
||
[];
|
||
|
||
return (
|
||
<div className="space-y-6" id="huntress-integration-settings">
|
||
{message && (
|
||
<Alert variant={message.kind === 'error' ? 'destructive' : 'default'}>
|
||
<AlertDescription>{message.text}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{!status?.is_connected ? (
|
||
<Card id="huntress-connect-card">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<ShieldAlert className="h-5 w-5" /> Connect Huntress
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Generate API credentials at <your-account>.huntress.io → API Credentials, then
|
||
paste them here. SOC-reviewed incident reports will become tickets automatically.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<Input
|
||
id="huntress-api-key"
|
||
type="password"
|
||
placeholder="API Key"
|
||
value={apiKey}
|
||
onChange={(e) => setApiKey(e.target.value)}
|
||
/>
|
||
<Input
|
||
id="huntress-api-secret"
|
||
type="password"
|
||
placeholder="API Secret Key"
|
||
value={apiSecret}
|
||
onChange={(e) => setApiSecret(e.target.value)}
|
||
/>
|
||
<Button
|
||
id="huntress-connect-button"
|
||
onClick={handleConnect}
|
||
disabled={isPending || !apiKey || !apiSecret}
|
||
>
|
||
{isPending ? 'Connecting…' : 'Connect'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
<Card id="huntress-status-card">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||
Connected to {status.account_name ?? 'Huntress'}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{status.organization_count} organizations ({status.unmapped_count} unmapped) ·{' '}
|
||
{status.open_alert_count} open incidents · last poll:{' '}
|
||
{status.last_poll_at ? new Date(status.last_poll_at).toLocaleString() : 'never'}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{status.sync_status === 'error' && status.sync_error && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>Last poll failed: {status.sync_error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{!status.routing_config_complete && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
Incident polling is paused until the routing configuration below is complete
|
||
(board, fallback client/board, and all three severity priorities).
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<Button
|
||
id="huntress-poll-now"
|
||
variant="outline"
|
||
onClick={handlePollNow}
|
||
disabled={isPending || !status.routing_config_complete}
|
||
>
|
||
<RefreshCw className="mr-1 h-4 w-4" /> Poll now
|
||
</Button>
|
||
<Button
|
||
id="huntress-disconnect"
|
||
variant="outline"
|
||
onClick={handleDisconnect}
|
||
disabled={isPending}
|
||
>
|
||
<Unlink className="mr-1 h-4 w-4" /> Disconnect
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card id="huntress-routing-card">
|
||
<CardHeader>
|
||
<CardTitle>Ticket Routing</CardTitle>
|
||
<CardDescription>
|
||
Where incident tickets land. Unmapped Huntress organizations always create tickets
|
||
on the fallback client and triage board — nothing is dropped.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Security board</label>
|
||
<CustomSelect
|
||
options={boardOptions}
|
||
value={boardId}
|
||
onValueChange={setBoardId}
|
||
placeholder="Select board"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Category (optional)</label>
|
||
<CustomSelect
|
||
options={categoryOptions}
|
||
value={categoryId ?? ''}
|
||
onValueChange={(v) => setCategoryId(v || null)}
|
||
placeholder="None"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Fallback client</label>
|
||
<ClientPicker
|
||
id="huntress-fallback-client"
|
||
clients={clients}
|
||
selectedClientId={fallbackClientId}
|
||
onSelect={(id) => setFallbackClientId(id)}
|
||
filterState="active"
|
||
clientTypeFilter="all"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Fallback (triage) board</label>
|
||
<CustomSelect
|
||
options={boardOptions}
|
||
value={fallbackBoardId}
|
||
onValueChange={setFallbackBoardId}
|
||
placeholder="Select board"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Critical severity →</label>
|
||
<CustomSelect
|
||
options={priorityOptions}
|
||
value={priorityCritical}
|
||
onValueChange={setPriorityCritical}
|
||
placeholder="Priority"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">High severity →</label>
|
||
<CustomSelect
|
||
options={priorityOptions}
|
||
value={priorityHigh}
|
||
onValueChange={setPriorityHigh}
|
||
placeholder="Priority"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Low severity →</label>
|
||
<CustomSelect
|
||
options={priorityOptions}
|
||
value={priorityLow}
|
||
onValueChange={setPriorityLow}
|
||
placeholder="Priority"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Poll interval (minutes)</label>
|
||
<Input
|
||
id="huntress-poll-interval"
|
||
type="number"
|
||
min={1}
|
||
max={60}
|
||
value={pollInterval}
|
||
onChange={(e) => setPollInterval(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-end gap-2 pb-1">
|
||
<input
|
||
id="huntress-auto-close"
|
||
type="checkbox"
|
||
checked={autoClose}
|
||
onChange={(e) => setAutoClose(e.target.checked)}
|
||
/>
|
||
<label htmlFor="huntress-auto-close" className="text-sm">
|
||
Close tickets when Huntress closes the incident
|
||
</label>
|
||
</div>
|
||
{autoClose && (
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium">Closed status</label>
|
||
<CustomSelect
|
||
options={closedStatusOptions}
|
||
value={closedStatusId}
|
||
onValueChange={setClosedStatusId}
|
||
placeholder="Select status"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Button id="huntress-save-routing" onClick={handleSaveRouting} disabled={isPending}>
|
||
{isPending ? 'Saving…' : 'Save routing configuration'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Organization mapping manager is added in the next task:
|
||
<HuntressOrganizationMappingManager refreshKey={mappingRefreshKey}
|
||
onMappingChanged={() => { setMappingRefreshKey((k) => k + 1); void loadAll(); }} /> */}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default HuntressIntegrationSettings;
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `cd ee/server && npm run typecheck`
|
||
Expected: clean. If `CustomSelect`'s `value` prop rejects `null`, pass `value={boardId ?? undefined}` (its prop type is `string | null | undefined` per `packages/ui/src/components/CustomSelect.tsx:41`).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx
|
||
git commit -m "feat(huntress): settings UI with connect and routing configuration"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Organization mapping manager UI (EE)
|
||
|
||
**Files:**
|
||
- Create: `ee/server/src/components/settings/integrations/huntress/OrganizationMappingManager.tsx`
|
||
- Modify: `ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx` (replace the placeholder comment)
|
||
|
||
- [ ] **Step 1: Create the mapping manager**
|
||
|
||
Create `ee/server/src/components/settings/integrations/huntress/OrganizationMappingManager.tsx` (adapted from the NinjaOne `OrganizationMappingManager.tsx` in the sibling `ninjaone/` directory):
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import React, { useCallback, useEffect, useState, useTransition } from 'react';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '@alga-psa/ui/components/Card';
|
||
import { Button } from '@alga-psa/ui/components/Button';
|
||
import { Alert, AlertDescription } from '@alga-psa/ui/components/Alert';
|
||
import { Badge } from '@alga-psa/ui/components/Badge';
|
||
import { ClientPicker } from '@alga-psa/ui/components/ClientPicker';
|
||
import { getAllClients } from '@alga-psa/clients/actions';
|
||
import type { IClient } from '@alga-psa/types';
|
||
import { Building2, RefreshCw } from 'lucide-react';
|
||
import {
|
||
getHuntressOrganizationMappings,
|
||
syncHuntressOrganizationMappings,
|
||
updateHuntressOrganizationMapping,
|
||
} from '../../../../lib/actions/integrations/huntressActions';
|
||
import type { RmmOrganizationMapping } from '../../../../interfaces/rmm.interfaces';
|
||
|
||
interface Props {
|
||
refreshKey?: number;
|
||
onMappingChanged?: () => void;
|
||
}
|
||
|
||
function isAutoMatched(mapping: RmmOrganizationMapping): boolean {
|
||
const metadata =
|
||
typeof mapping.metadata === 'string'
|
||
? (() => {
|
||
try {
|
||
return JSON.parse(mapping.metadata as unknown as string);
|
||
} catch {
|
||
return {};
|
||
}
|
||
})()
|
||
: mapping.metadata ?? {};
|
||
return (metadata as Record<string, unknown>).auto_matched === true;
|
||
}
|
||
|
||
const HuntressOrganizationMappingManager: React.FC<Props> = ({ refreshKey, onMappingChanged }) => {
|
||
const [mappings, setMappings] = useState<RmmOrganizationMapping[]>([]);
|
||
const [clients, setClients] = useState<IClient[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [isPending, startTransition] = useTransition();
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [mappingsResult, clientsResult] = await Promise.all([
|
||
getHuntressOrganizationMappings(),
|
||
getAllClients(false),
|
||
]);
|
||
setMappings(mappingsResult);
|
||
setClients(clientsResult ?? []);
|
||
} catch {
|
||
setError('Failed to load organization mappings');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load, refreshKey]);
|
||
|
||
const handleSync = () => {
|
||
startTransition(async () => {
|
||
const result = await syncHuntressOrganizationMappings();
|
||
if (!result.success) setError(result.error ?? 'Sync failed');
|
||
await load();
|
||
onMappingChanged?.();
|
||
});
|
||
};
|
||
|
||
const handleClientChange = (mappingId: string, clientId: string | null) => {
|
||
startTransition(async () => {
|
||
const result = await updateHuntressOrganizationMapping(mappingId, { client_id: clientId });
|
||
if (!result.success) setError(result.error ?? 'Failed to update mapping');
|
||
await load();
|
||
onMappingChanged?.();
|
||
});
|
||
};
|
||
|
||
const handleAutoCreateToggle = (mappingId: string, enabled: boolean) => {
|
||
startTransition(async () => {
|
||
const result = await updateHuntressOrganizationMapping(mappingId, {
|
||
auto_create_tickets: enabled,
|
||
});
|
||
if (!result.success) setError(result.error ?? 'Failed to update mapping');
|
||
await load();
|
||
onMappingChanged?.();
|
||
});
|
||
};
|
||
|
||
const unmappedCount = mappings.filter((m) => !m.client_id).length;
|
||
|
||
return (
|
||
<Card id="huntress-org-mappings">
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Building2 className="h-5 w-5" /> Organization Mapping
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Map Huntress organizations to clients. Incidents for unmapped organizations go to the
|
||
fallback client and triage board{unmappedCount > 0 ? ` (${unmappedCount} unmapped)` : ''}.
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
id="huntress-sync-orgs"
|
||
variant="outline"
|
||
onClick={handleSync}
|
||
disabled={isPending}
|
||
>
|
||
<RefreshCw className="mr-1 h-4 w-4" /> Sync organizations
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{error && (
|
||
<Alert variant="destructive" className="mb-4">
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{loading ? (
|
||
<p className="py-6 text-center text-sm text-muted-foreground">Loading…</p>
|
||
) : mappings.length === 0 ? (
|
||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||
No organizations yet — click "Sync organizations".
|
||
</p>
|
||
) : (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-left text-muted-foreground">
|
||
<th className="py-2 pr-4 font-medium">Huntress Organization</th>
|
||
<th className="py-2 pr-4 font-medium">Alga Client</th>
|
||
<th className="py-2 pr-4 font-medium">Create Tickets</th>
|
||
<th className="py-2 font-medium">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{mappings.map((mapping) => (
|
||
<tr key={mapping.mapping_id} className="border-b last:border-0">
|
||
<td className="py-2 pr-4">{mapping.external_organization_name}</td>
|
||
<td className="py-2 pr-4">
|
||
<ClientPicker
|
||
id={`huntress-client-picker-${mapping.mapping_id}`}
|
||
clients={clients}
|
||
selectedClientId={mapping.client_id ?? null}
|
||
onSelect={(clientId) => handleClientChange(mapping.mapping_id, clientId)}
|
||
filterState="active"
|
||
clientTypeFilter="all"
|
||
/>
|
||
</td>
|
||
<td className="py-2 pr-4">
|
||
<input
|
||
id={`huntress-auto-create-${mapping.mapping_id}`}
|
||
type="checkbox"
|
||
checked={mapping.auto_create_tickets !== false}
|
||
onChange={(e) =>
|
||
handleAutoCreateToggle(mapping.mapping_id, e.target.checked)
|
||
}
|
||
/>
|
||
</td>
|
||
<td className="py-2">
|
||
{mapping.client_id ? (
|
||
isAutoMatched(mapping) ? (
|
||
<Badge variant="secondary">Auto-matched</Badge>
|
||
) : (
|
||
<Badge variant="default">Mapped</Badge>
|
||
)
|
||
) : (
|
||
<Badge variant="outline">Unmapped → triage</Badge>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default HuntressOrganizationMappingManager;
|
||
```
|
||
|
||
- [ ] **Step 2: Embed it in the settings component**
|
||
|
||
In `ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx`, add the import:
|
||
|
||
```typescript
|
||
import HuntressOrganizationMappingManager from './huntress/OrganizationMappingManager';
|
||
```
|
||
|
||
and replace the placeholder comment block (`{/* Organization mapping manager is added in the next task: ... */}`) with:
|
||
|
||
```tsx
|
||
<HuntressOrganizationMappingManager
|
||
refreshKey={mappingRefreshKey}
|
||
onMappingChanged={() => {
|
||
setMappingRefreshKey((k) => k + 1);
|
||
void loadAll();
|
||
}}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `cd ee/server && npm run typecheck`
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add ee/server/src/components/settings/integrations/huntress/OrganizationMappingManager.tsx ee/server/src/components/settings/integrations/HuntressIntegrationSettings.tsx
|
||
git commit -m "feat(huntress): organization mapping manager UI"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Full verification + smoke checklist
|
||
|
||
**Files:** none new.
|
||
|
||
- [ ] **Step 1: Run the entire Huntress test suite**
|
||
|
||
Run: `cd ee/server && npx vitest run src/__tests__/unit/huntress src/__tests__/integration/huntressIncidentProcessor.integration.test.ts src/__tests__/integration/huntressOrgSync.integration.test.ts`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 2: Typecheck + lint both apps**
|
||
|
||
Run: `cd ee/server && npm run typecheck && npm run lint`
|
||
Expected: no new errors versus the pre-branch baseline.
|
||
|
||
- [ ] **Step 3: Confirm CE builds don't pull EE code**
|
||
|
||
Run: `grep -rn "integrations/huntress" server/src packages/integrations/src | grep -v "@enterprise"`
|
||
Expected: no output — the only CE-side references go through the `@enterprise` alias (stubbed in `packages/ee/src`).
|
||
|
||
- [ ] **Step 4: Manual smoke (dev stack, real or sandbox Huntress account)**
|
||
|
||
1. Start the dev environment (alga-dev-env-manager skill) with `NEXT_PUBLIC_EDITION=enterprise`.
|
||
2. Settings → Integrations → RMM & Security: the **Security** section shows the Huntress card.
|
||
3. Connect with real API credentials → account name appears; organizations list populates; exact-name orgs show "Auto-matched".
|
||
4. Configure routing (board, fallback client/board, three priorities) → the "polling paused" banner disappears.
|
||
5. Click **Poll now** → incidents within the backfill window appear as tickets on the security board with correct priority; open one ticket and verify the SOC summary, host details, and that the **portal deep link resolves** (if Huntress redirects, correct the path in `buildPortalUrl` — one function, one line).
|
||
6. Re-run **Poll now** → no duplicate tickets.
|
||
7. Unmap an org, create/find an incident for it → ticket lands on the fallback client + triage board with the `[Unmapped Org]` prefix.
|
||
8. Close an incident in Huntress (or wait for one to close), poll → internal note appended; with auto-close enabled, ticket moves to the closed status.
|
||
|
||
- [ ] **Step 5: Final commit (if any fixes were made)**
|
||
|
||
```bash
|
||
git add -A && git commit -m "fix(huntress): verification fixes"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review checklist (for the plan executor)
|
||
|
||
- Spec coverage: incident→ticket (Tasks 8, 10, 11), fail-safe org mapping + auto-match (Tasks 3, 8, 9, 15), routing config (Tasks 2, 12, 14), dedup/update-in-place (Tasks 6, 8), self-contained tickets + deep link (Task 5), polling + cursor + backfill (Tasks 4, 10), scheduling (Task 11), EE gating + Security section (Tasks 13–15).
|
||
- Deferred per spec (do NOT build): webhooks, write-back resolutions, escalations/signals, per-client routing overrides, Huntress asset-sync engine.
|
||
- Type names used across tasks: `HuntressSettings`, `HuntressSeverityPriorityMap`, `HuntressIncidentReport`, `HuntressPollInput/Result`, `IncidentAction`, `ProcessIncidentDeps` — defined once each in Tasks 1, 2, 6, 8, 10.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|