chore: merge executor worktree (worktree-agent-a5b1c5010c37561a1)
This commit is contained in:
commit
2c70747822
8 changed files with 531 additions and 0 deletions
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 01
|
||||
subsystem: api
|
||||
tags: [vitest, tdd, postgres, phishing-triage, pure-functions]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 19-phishing-triage-classification-engine
|
||||
provides: campaign-classifier.ts's mapVerdictToActions 7-action vocabulary and postgresClient/vi.mock testing idiom
|
||||
- phase: 20-remediation-approval-audit-safety
|
||||
provides: remediation-service.ts's ApproveActionInput shape and phishing-audit.ts's canonical event_type values
|
||||
provides:
|
||||
- resolveTicketToCampaign(ticketId) — pure ticket->campaign lookup service
|
||||
- deriveDefaultParams(actionType, evidence) — 7-action-type default-param derivation
|
||||
- mergeTimeline(reports, classifications, auditEvents) — chronological 3-source merge
|
||||
affects: [22-02-ticket-campaign-route, 22-04-action-area-card, 22-05-timeline-card]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Pure lib/services/*.ts extraction for route/component logic that needs vitest coverage (test.include is lib/**/*.test.ts only)"
|
||||
- "vi.mock('./postgres-client', () => ({ postgresClient: { query: ... } })) factory-mock idiom (mirrors campaign-classifier.test.ts)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- lib/services/phishing-ticket-resolver.ts
|
||||
- lib/services/phishing-ticket-resolver.test.ts
|
||||
- lib/services/remediation-default-params.ts
|
||||
- lib/services/remediation-default-params.test.ts
|
||||
- lib/services/phishing-timeline.ts
|
||||
- lib/services/phishing-timeline.test.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Used named import `{ postgresClient }` from './postgres-client' (matches the sibling files' actual convention — campaign-classifier.ts, remediation-service.ts, phishing-audit.ts — and the PATTERNS.md code snippet), not the plan action text's parenthetical 'default import' gloss, since the vi.mock factory only provides a named `postgresClient` export and a default-import call site would not be mocked."
|
||||
- "mergeTimeline's report/classification/audit source shapes use camelCase `createdAt` fields (not snake_case `created_at`) since the read_first note specifies these consume the already-transformed camelCase API response shapes from app/api/phishing/campaigns/[id]/route.ts."
|
||||
|
||||
patterns-established:
|
||||
- "Pattern: exhaustive switch with `default: return {}` for extensible action-type-keyed pure derivations (mirrors mapVerdictToActions)"
|
||||
- "Pattern: discriminated union + fixed KIND_PRIORITY map for stable multi-source chronological merges"
|
||||
|
||||
requirements-completed: [REVIEW-01, REVIEW-02, REVIEW-04]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 22 Plan 01: Wave 0 Pure-Logic Extraction Summary
|
||||
|
||||
**Three pure `lib/services/*.ts` modules (ticket->campaign resolver, 7-action-type default-param derivation, 3-source timeline merge) extracted and unit-tested under vitest, unblocking the route/component work in plans 02/04/05.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-07-16T18:17:00Z
|
||||
- **Completed:** 2026-07-16T18:29:00Z
|
||||
- **Tasks:** 3 completed
|
||||
- **Files modified:** 6 (all new)
|
||||
|
||||
## Accomplishments
|
||||
- `resolveTicketToCampaign(ticketId)` covers all three resolution states (no report / ungrouped / grouped) via a single parameterized `reports` lookup
|
||||
- `deriveDefaultParams(actionType, evidence)` implements the exact 7-action-type default-params table from the UI-SPEC, with a safe `{}` fallback for unknown/future action types
|
||||
- `mergeTimeline(reports, classifications, auditEvents)` produces a single ascending-chronological array with a discriminated `TimelineEntry` union and a deterministic tie-break order
|
||||
- All 3 modules are pure or DB-only (no `NextResponse`/`requirePermission` in any of them) — route-free per the plan's design intent
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed via full TDD RED/GREEN pairs:
|
||||
|
||||
1. **Task 1: phishing-ticket-resolver.ts + test** — `20b1e1b` (test), `e619321` (feat)
|
||||
2. **Task 2: remediation-default-params.ts + test** — `c64a905` (test), `86cffc4` (feat)
|
||||
3. **Task 3: phishing-timeline.ts + test (server-merge)** — `37f6657` (test), `d30a49f` (feat)
|
||||
|
||||
**Plan metadata:** (this commit)
|
||||
|
||||
## Files Created/Modified
|
||||
- `lib/services/phishing-ticket-resolver.ts` - `resolveTicketToCampaign(ticketId)`, parameterized `reports` lookup
|
||||
- `lib/services/phishing-ticket-resolver.test.ts` - 4 tests covering the 3 resolution states + query-shape assertion
|
||||
- `lib/services/remediation-default-params.ts` - `deriveDefaultParams(actionType, evidence)`, exhaustive switch over 7 action types + fallback
|
||||
- `lib/services/remediation-default-params.test.ts` - 9 tests covering all 7 known types, unknown fallback, and null-evidence defaults
|
||||
- `lib/services/phishing-timeline.ts` - `mergeTimeline(reports, classifications, auditEvents)`, `TimelineEntry` discriminated union
|
||||
- `lib/services/phishing-timeline.test.ts` - 4 tests covering sort order, length invariant, per-variant fields, and tie-break stability
|
||||
|
||||
## Decisions Made
|
||||
- Named import for `postgresClient` (not the plan text's literal "default import" parenthetical) — see `key-decisions` above; verified against all 3 sibling files (`campaign-classifier.ts`, `remediation-service.ts`, `phishing-audit.ts`) and the working `vi.mock` test pattern.
|
||||
- `mergeTimeline` inputs are camelCase (`createdAt`, `reportId`, `ticketNumber`, `companyName`, `verdict`, `confidence`, `eventType`, `actor`, `payload`) since this function consumes the campaign-detail route's already-transformed response shape, not raw DB rows.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written, with one clarifying correction to an internal inconsistency in the plan text (see Decisions Made: named vs. default import) resolved in favor of the working, testable convention already established in this phase's sibling files.
|
||||
|
||||
## Issues Encountered
|
||||
None - all three RED phases confirmed failing before implementation (module-not-found), all three GREEN phases passed on first implementation attempt.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
All three tasks followed the full RED -> GREEN cycle with separate commits:
|
||||
- Task 1: `test(22-01)` at `20b1e1b` -> `feat(22-01)` at `e619321`
|
||||
- Task 2: `test(22-01)` at `c64a905` -> `feat(22-01)` at `86cffc4`
|
||||
- Task 3: `test(22-01)` at `37f6657` -> `feat(22-01)` at `d30a49f`
|
||||
|
||||
No REFACTOR commits were needed — each GREEN implementation passed cleanly without follow-up cleanup.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 02 (ticket->campaign resolver route) can now call `resolveTicketToCampaign` directly — the route becomes a thin `requirePermission` + param-parse + call wrapper per PATTERNS.md.
|
||||
- Plans 04/05 (Action Area Card, Timeline Card) have a tested `deriveDefaultParams`/`mergeTimeline` to call from their respective routes/components (or the extended `campaigns/[id]` route, per the server-merge decision).
|
||||
- Full test suite: 411/413 passing. The 2 failures are pre-existing, unrelated `lib/services/analyzer/itglue-search.test.ts` failures (untouched by this plan) — logged in `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md`, out of scope per the plan's file list.
|
||||
- `npx tsc --noEmit --pretty` is clean.
|
||||
|
||||
---
|
||||
*Phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve*
|
||||
*Completed: 2026-07-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All 6 created files verified present on disk; all 6 task commit hashes
|
||||
(`20b1e1b`, `e619321`, `c64a905`, `86cffc4`, `37f6657`, `d30a49f`) verified
|
||||
present in `git log --oneline --all`.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Deferred Items — Phase 22
|
||||
|
||||
Pre-existing failures/warnings discovered during execution but out of scope
|
||||
for the current plan (not touched by this plan's files, not caused by this
|
||||
plan's changes).
|
||||
|
||||
## Plan 01
|
||||
|
||||
- `lib/services/analyzer/itglue-search.test.ts` — 2 pre-existing failing
|
||||
tests (`itglueSearch > tolerates per-call failures ...`, doc count
|
||||
mismatches). Unrelated to phishing-ticket-resolver /
|
||||
remediation-default-params / phishing-timeline (this plan's files). Not
|
||||
modified by any commit in this plan. Confirmed present before this plan's
|
||||
changes (file untouched in `git diff`). Full-suite run: `2 failed | 411
|
||||
passed (413)` with only these 2 pre-existing failures — all other tests,
|
||||
including the 3 new files added by this plan, are green.
|
||||
76
lib/services/phishing-ticket-resolver.test.ts
Normal file
76
lib/services/phishing-ticket-resolver.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock postgresClient BEFORE importing the module under test — mirrors
|
||||
// campaign-classifier.test.ts's vi.mock() factory-mocking discipline.
|
||||
const queryMock = vi.fn();
|
||||
vi.mock('./postgres-client', () => ({
|
||||
postgresClient: {
|
||||
query: (...args: unknown[]) => queryMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
||||
import { resolveTicketToCampaign } from './phishing-ticket-resolver';
|
||||
|
||||
describe('resolveTicketToCampaign', () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns { found: false } when no reports row exists for the ticket id', async () => {
|
||||
queryMock.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
const result = await resolveTicketToCampaign(999);
|
||||
|
||||
expect(result).toEqual({ found: false });
|
||||
});
|
||||
|
||||
it('returns { found: true, campaignId: null } for an ungrouped report', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
rows: [{ id: 'report-1', campaign_id: null, ticket_number: 'T20260716.0001' }],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
const result = await resolveTicketToCampaign(123);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
reportId: 'report-1',
|
||||
campaignId: null,
|
||||
ticketNumber: 'T20260716.0001',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns { found: true, reportId, campaignId, ticketNumber } for a grouped report', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
id: 'report-2',
|
||||
campaign_id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
ticket_number: 'T20260716.0002',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
const result = await resolveTicketToCampaign(456);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
reportId: 'report-2',
|
||||
campaignId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
ticketNumber: 'T20260716.0002',
|
||||
});
|
||||
});
|
||||
|
||||
it('queries reports filtered by ticket_id with a parameterized query', async () => {
|
||||
queryMock.mockResolvedValue({ rows: [], rowCount: 0 });
|
||||
|
||||
await resolveTicketToCampaign(1);
|
||||
|
||||
expect(queryMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FROM reports WHERE ticket_id = $1'),
|
||||
[1]
|
||||
);
|
||||
});
|
||||
});
|
||||
48
lib/services/phishing-ticket-resolver.ts
Normal file
48
lib/services/phishing-ticket-resolver.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Phishing Ticket -> Campaign Resolver (Phase 22, Wave 0)
|
||||
*
|
||||
* Pure lookup service: given an Autotask ticket id, finds the linked
|
||||
* `reports` row (if any) and reports back its campaign linkage. No auth,
|
||||
* no param parsing — that lives in the route (plan 02, GET
|
||||
* /api/phishing/tickets/[ticket_id]/campaign). Extracted as its own module
|
||||
* so it is unit-testable under vitest (whose `test.include` is
|
||||
* `lib/**\/*.test.ts` only — `app/**` route files have no coverage).
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
export interface TicketCampaignResolution {
|
||||
found: boolean;
|
||||
reportId?: string;
|
||||
campaignId?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
}
|
||||
|
||||
interface ReportLookupRow {
|
||||
id: string;
|
||||
campaign_id: string | null;
|
||||
ticket_number: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the `reports` row for a given ticket id (tickets.id / reports.ticket_id).
|
||||
* - No row: `{ found: false }`
|
||||
* - Row with `campaign_id = null`: `{ found: true, reportId, campaignId: null, ticketNumber }` — ungrouped report
|
||||
* - Row with a `campaign_id`: `{ found: true, reportId, campaignId, ticketNumber }` — grouped into a campaign
|
||||
*/
|
||||
export async function resolveTicketToCampaign(ticketId: number): Promise<TicketCampaignResolution> {
|
||||
const res = await postgresClient.query<ReportLookupRow>(
|
||||
`SELECT id::text, campaign_id::text, ticket_number FROM reports WHERE ticket_id = $1`,
|
||||
[ticketId]
|
||||
);
|
||||
const report = res.rows[0];
|
||||
if (!report) {
|
||||
return { found: false };
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
reportId: report.id,
|
||||
campaignId: report.campaign_id,
|
||||
ticketNumber: report.ticket_number,
|
||||
};
|
||||
}
|
||||
63
lib/services/phishing-timeline.test.ts
Normal file
63
lib/services/phishing-timeline.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeTimeline, type TimelineEntry } from './phishing-timeline';
|
||||
|
||||
describe('mergeTimeline', () => {
|
||||
const reports = [
|
||||
{ createdAt: '2026-07-15T00:00:00.000Z', reportId: 'report-1', ticketNumber: 'T-0001', companyName: 'Acme' },
|
||||
{ createdAt: '2026-07-15T04:00:00.000Z', reportId: 'report-2', ticketNumber: 'T-0002', companyName: 'Acme' },
|
||||
];
|
||||
const classifications = [
|
||||
{ createdAt: '2026-07-15T02:00:00.000Z', verdict: 'THREAT', confidence: '85' },
|
||||
];
|
||||
const auditEvents = [
|
||||
{ createdAt: '2026-07-15T01:00:00.000Z', eventType: 'remediation_approved', actor: 'lorentz@wulfconsulting.com', payload: { actions: ['block_sender'] } },
|
||||
{ createdAt: '2026-07-15T05:00:00.000Z', eventType: 'remediation_completed', actor: null, payload: {} },
|
||||
];
|
||||
|
||||
it('returns a single array sorted ascending by `at` across all 3 sources', () => {
|
||||
const result = mergeTimeline(reports, classifications, auditEvents);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
const timestamps = result.map((entry) => new Date(entry.at).getTime());
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('output length equals the sum of the three input array lengths', () => {
|
||||
const result = mergeTimeline(reports, classifications, auditEvents);
|
||||
expect(result).toHaveLength(reports.length + classifications.length + auditEvents.length);
|
||||
});
|
||||
|
||||
it('carries a discriminant `kind` and the source-specific fields for each entry', () => {
|
||||
const result = mergeTimeline(reports, classifications, auditEvents);
|
||||
|
||||
const reportEntry = result.find((e) => e.kind === 'report' && e.reportId === 'report-1');
|
||||
expect(reportEntry).toMatchObject({ kind: 'report', reportId: 'report-1', ticketNumber: 'T-0001', companyName: 'Acme' });
|
||||
|
||||
const classificationEntry = result.find((e) => e.kind === 'classification');
|
||||
expect(classificationEntry).toMatchObject({ kind: 'classification', verdict: 'THREAT', confidence: '85' });
|
||||
|
||||
const auditEntry = result.find((e) => e.kind === 'audit' && e.eventType === 'remediation_approved');
|
||||
expect(auditEntry).toMatchObject({
|
||||
kind: 'audit',
|
||||
eventType: 'remediation_approved',
|
||||
actor: 'lorentz@wulfconsulting.com',
|
||||
payload: { actions: ['block_sender'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a stable tie-break order (report, then classification, then audit) on identical timestamps and does not throw', () => {
|
||||
const tiedAt = '2026-07-15T10:00:00.000Z';
|
||||
const tiedReports = [{ createdAt: tiedAt, reportId: 'r', ticketNumber: 'T', companyName: 'C' }];
|
||||
const tiedClassifications = [{ createdAt: tiedAt, verdict: 'SPAM', confidence: '50' }];
|
||||
const tiedAudit = [{ createdAt: tiedAt, eventType: 'campaign_marked_false_positive', actor: null, payload: {} }];
|
||||
|
||||
let result: TimelineEntry[] = [];
|
||||
expect(() => {
|
||||
result = mergeTimeline(tiedReports, tiedClassifications, tiedAudit);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(result.map((e) => e.kind)).toEqual(['report', 'classification', 'audit']);
|
||||
});
|
||||
});
|
||||
85
lib/services/phishing-timeline.ts
Normal file
85
lib/services/phishing-timeline.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Phishing Campaign Timeline Merge (Phase 22, Wave 0, REVIEW-02)
|
||||
*
|
||||
* Pure transform: merges the three already-camelCased sources consumed by
|
||||
* `TimelineCard` (reports, classifications, audit_events — see
|
||||
* app/api/phishing/campaigns/[id]/route.ts's response shape and
|
||||
* lib/services/phishing-audit.ts's canonical event_type values) into a
|
||||
* single ascending-by-timestamp array. Server-merge approach chosen per
|
||||
* 22-RESEARCH.md Alternatives Considered. No DB/fetch imports — pure
|
||||
* function, takes already-fetched row arrays.
|
||||
*/
|
||||
|
||||
export type TimelineEntry =
|
||||
| { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }
|
||||
| { kind: 'classification'; at: string; verdict: string; confidence: string | null }
|
||||
| { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown };
|
||||
|
||||
interface ReportTimelineSource {
|
||||
createdAt: string;
|
||||
reportId: string;
|
||||
ticketNumber: string | null;
|
||||
companyName: string | null;
|
||||
}
|
||||
|
||||
interface ClassificationTimelineSource {
|
||||
createdAt: string;
|
||||
verdict: string;
|
||||
confidence: string | null;
|
||||
}
|
||||
|
||||
interface AuditTimelineSource {
|
||||
createdAt: string;
|
||||
eventType: string;
|
||||
actor: string | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/** Fixed tie-break priority when two entries share an identical `at` timestamp. */
|
||||
const KIND_PRIORITY: Record<TimelineEntry['kind'], number> = {
|
||||
report: 0,
|
||||
classification: 1,
|
||||
audit: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps each of the three sources into its `TimelineEntry` variant,
|
||||
* concatenates, and sorts ascending by `at` (oldest first — matches the
|
||||
* existing `ORDER BY created_at ASC` convention). Ties on identical
|
||||
* timestamps fall back to a fixed kind priority (report < classification <
|
||||
* audit) for a stable, deterministic order.
|
||||
*/
|
||||
export function mergeTimeline(
|
||||
reports: ReportTimelineSource[],
|
||||
classifications: ClassificationTimelineSource[],
|
||||
auditEvents: AuditTimelineSource[]
|
||||
): TimelineEntry[] {
|
||||
const entries: TimelineEntry[] = [
|
||||
...reports.map<TimelineEntry>((report) => ({
|
||||
kind: 'report',
|
||||
at: report.createdAt,
|
||||
reportId: report.reportId,
|
||||
ticketNumber: report.ticketNumber,
|
||||
companyName: report.companyName,
|
||||
})),
|
||||
...classifications.map<TimelineEntry>((classification) => ({
|
||||
kind: 'classification',
|
||||
at: classification.createdAt,
|
||||
verdict: classification.verdict,
|
||||
confidence: classification.confidence,
|
||||
})),
|
||||
...auditEvents.map<TimelineEntry>((event) => ({
|
||||
kind: 'audit',
|
||||
at: event.createdAt,
|
||||
eventType: event.eventType,
|
||||
actor: event.actor,
|
||||
payload: event.payload,
|
||||
})),
|
||||
];
|
||||
|
||||
return entries.sort((a, b) => {
|
||||
const diff = new Date(a.at).getTime() - new Date(b.at).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind];
|
||||
});
|
||||
}
|
||||
75
lib/services/remediation-default-params.test.ts
Normal file
75
lib/services/remediation-default-params.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveDefaultParams, type DefaultParamEvidence } from './remediation-default-params';
|
||||
|
||||
const filledEvidence: DefaultParamEvidence = {
|
||||
requesterEmail: 'requester@wulfconsulting.test',
|
||||
senderEmail: 'attacker@evil.test',
|
||||
senderDomain: 'evil.test',
|
||||
messageId: 'message-123',
|
||||
};
|
||||
|
||||
const emptyEvidence: DefaultParamEvidence = {
|
||||
requesterEmail: null,
|
||||
senderEmail: null,
|
||||
senderDomain: null,
|
||||
messageId: null,
|
||||
};
|
||||
|
||||
describe('deriveDefaultParams', () => {
|
||||
it('returns {} for no_action', () => {
|
||||
expect(deriveDefaultParams('no_action', filledEvidence)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns recipientEmail + empty message for warn_user, using requesterEmail', () => {
|
||||
expect(deriveDefaultParams('warn_user', filledEvidence)).toEqual({
|
||||
recipientEmail: 'requester@wulfconsulting.test',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns senderEmail + senderDomain for block_sender', () => {
|
||||
expect(deriveDefaultParams('block_sender', filledEvidence)).toEqual({
|
||||
senderEmail: 'attacker@evil.test',
|
||||
senderDomain: 'evil.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns messageId + empty mailboxes for purge_message', () => {
|
||||
expect(deriveDefaultParams('purge_message', filledEvidence)).toEqual({
|
||||
messageId: 'message-123',
|
||||
mailboxes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns userPrincipalName (from requesterEmail) for reset_password', () => {
|
||||
expect(deriveDefaultParams('reset_password', filledEvidence)).toEqual({
|
||||
userPrincipalName: 'requester@wulfconsulting.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty deviceId for isolate_endpoint', () => {
|
||||
expect(deriveDefaultParams('isolate_endpoint', filledEvidence)).toEqual({ deviceId: '' });
|
||||
});
|
||||
|
||||
it('returns userPrincipalName + empty ruleName for disable_forwarding_rule', () => {
|
||||
expect(deriveDefaultParams('disable_forwarding_rule', filledEvidence)).toEqual({
|
||||
userPrincipalName: 'requester@wulfconsulting.test',
|
||||
ruleName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns {} for an unknown/future action type', () => {
|
||||
expect(deriveDefaultParams('unknown_future_type', filledEvidence)).toEqual({});
|
||||
});
|
||||
|
||||
it('falls back to empty-string defaults for warn_user/block_sender/reset_password/disable_forwarding_rule when evidence fields are null', () => {
|
||||
expect(deriveDefaultParams('warn_user', emptyEvidence)).toEqual({ recipientEmail: '', message: '' });
|
||||
expect(deriveDefaultParams('block_sender', emptyEvidence)).toEqual({ senderEmail: '', senderDomain: '' });
|
||||
expect(deriveDefaultParams('purge_message', emptyEvidence)).toEqual({ messageId: '', mailboxes: [] });
|
||||
expect(deriveDefaultParams('reset_password', emptyEvidence)).toEqual({ userPrincipalName: '' });
|
||||
expect(deriveDefaultParams('disable_forwarding_rule', emptyEvidence)).toEqual({
|
||||
userPrincipalName: '',
|
||||
ruleName: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
42
lib/services/remediation-default-params.ts
Normal file
42
lib/services/remediation-default-params.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Remediation Default-Params Derivation (Phase 22, Wave 0)
|
||||
*
|
||||
* Pure transform: given an action type (from campaign-classifier.ts's
|
||||
* `mapVerdictToActions` vocabulary) and bounded evidence fields, returns the
|
||||
* default `params` object an operator sees pre-filled in the Action Area
|
||||
* before approving (feeds `ApproveActionInput.params` in
|
||||
* lib/services/remediation-service.ts). No DB/fetch imports — pure function.
|
||||
*/
|
||||
|
||||
export interface DefaultParamEvidence {
|
||||
requesterEmail: string | null;
|
||||
senderEmail: string | null;
|
||||
senderDomain: string | null;
|
||||
messageId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive per-action-type default-params table (UI-SPEC Action Area
|
||||
* Spec). Unknown/future action types fall through to `{}` rather than
|
||||
* throwing, so newly introduced action types never break the Action Area.
|
||||
*/
|
||||
export function deriveDefaultParams(actionType: string, evidence: DefaultParamEvidence): Record<string, unknown> {
|
||||
switch (actionType) {
|
||||
case 'no_action':
|
||||
return {};
|
||||
case 'warn_user':
|
||||
return { recipientEmail: evidence.requesterEmail ?? '', message: '' };
|
||||
case 'block_sender':
|
||||
return { senderEmail: evidence.senderEmail ?? '', senderDomain: evidence.senderDomain ?? '' };
|
||||
case 'purge_message':
|
||||
return { messageId: evidence.messageId ?? '', mailboxes: [] };
|
||||
case 'reset_password':
|
||||
return { userPrincipalName: evidence.requesterEmail ?? '' };
|
||||
case 'isolate_endpoint':
|
||||
return { deviceId: '' };
|
||||
case 'disable_forwarding_rule':
|
||||
return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue