PSA/shared/services/email/__tests__/inboundEmailRules.engine.test.ts
Hermes 284313f908
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
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

479 lines
15 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { evaluateInboundEmailRules } from '../inboundEmailRules/engine';
import type {
InboundEmailRule,
InboundEmailRuleEngineDeps,
} from '../inboundEmailRules';
const TENANT = 'tenant-1';
const PROVIDER = 'provider-1';
function makeEmailData(overrides: Record<string, unknown> = {}) {
return {
id: 'email-1',
from: { email: 'alerts@huntress.com' },
to: [{ email: 'support@msp.com' }],
subject: 'Critical Alert (Acme Corp) - EDR detection',
body: { text: 'Incident details follow.' },
...overrides,
};
}
function makeRule(overrides: Partial<InboundEmailRule> = {}): InboundEmailRule {
return {
tenant: TENANT,
id: 'rule-1',
name: 'Test rule',
is_active: true,
position: 1,
provider_ids: null,
conditions: [{ field: 'from_address', operator: 'contains', value: '@huntress.com' }],
action_type: 'skip',
action_config: {},
on_no_match: 'proceed',
fallback_inbound_ticket_defaults_id: null,
...overrides,
};
}
function makeDeps(overrides: Partial<InboundEmailRuleEngineDeps> = {}): InboundEmailRuleEngineDeps {
return {
loadRules: vi.fn(async () => []),
matchClientByName: vi.fn(async () => null),
resolveDefaultsById: vi.fn(async () => null),
classifyWithAi: vi.fn(async () => ({ decision: 'no_decision' as const })),
...overrides,
};
}
describe('inboundEmailRules engine: rule selection', () => {
it('returns none when the tenant has no rules', async () => {
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps: makeDeps(),
});
expect(result.outcome).toEqual({ kind: 'none' });
});
it('applies rules with provider_ids = null to all providers', async () => {
const deps = makeDeps({ loadRules: vi.fn(async () => [makeRule({ provider_ids: null })]) });
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome.kind).toBe('skip');
});
it('skips rules scoped to a different provider', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [makeRule({ provider_ids: ['other-provider'] })]),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace[0].resolution).toBe('provider_filtered');
});
it('executes rules scoped to the receiving provider', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [makeRule({ provider_ids: ['other-provider', PROVIDER] })]),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome.kind).toBe('skip');
});
it('first matching rule wins; later matching rules are not executed', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
makeRule({ id: 'rule-1', name: 'First' }),
makeRule({ id: 'rule-2', name: 'Second' }),
]),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toMatchObject({ kind: 'skip', ruleId: 'rule-1' });
expect(result.trace).toHaveLength(1);
});
it('rules whose conditions do not match are passed over', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
makeRule({
id: 'rule-1',
conditions: [{ field: 'subject', operator: 'contains', value: 'no-such-text' }],
}),
makeRule({ id: 'rule-2' }),
]),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toMatchObject({ kind: 'skip', ruleId: 'rule-2' });
expect(result.trace[0].resolution).toBe('conditions_not_matched');
});
});
describe('inboundEmailRules engine: extract_assign_client', () => {
const extractRule = makeRule({
id: 'extract-rule',
action_type: 'extract_assign_client',
action_config: {
source: 'subject',
extraction: { type: 'between', start: '(', end: ')' },
},
});
it('assigns the client when extraction and matching succeed', async () => {
const matchClientByName = vi.fn(async () => ({ clientId: 'client-acme', matchedBy: 'client_name' as const }));
const deps = makeDeps({
loadRules: vi.fn(async () => [extractRule]),
matchClientByName,
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(matchClientByName).toHaveBeenCalledWith(TENANT, 'acme corp');
expect(result.outcome).toMatchObject({
kind: 'assign_client',
clientId: 'client-acme',
extractedValue: 'acme corp',
matchSource: 'rule_extraction',
});
});
it('on_no_match=proceed continues to later rules when no client matches', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
{ ...extractRule, on_no_match: 'proceed' as const },
makeRule({ id: 'catch-all', name: 'Catch-all skip' }),
]),
matchClientByName: vi.fn(async () => null),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.trace[0].resolution).toBe('no_match_proceed');
expect(result.outcome).toMatchObject({ kind: 'skip', ruleId: 'catch-all' });
});
it('on_no_match=proceed with no later match returns none', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [extractRule]),
matchClientByName: vi.fn(async () => null),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
});
it('on_no_match=skip stops and skips the email', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [{ ...extractRule, on_no_match: 'skip' as const }]),
matchClientByName: vi.fn(async () => null),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toMatchObject({ kind: 'skip', via: 'on_no_match' });
});
it('on_no_match=fallback_destination resolves the fallback defaults set', async () => {
const fallbackDefaults = { board_id: 'triage-board', status_id: 's', priority_id: 'p' };
const resolveDefaultsById = vi.fn(async () => fallbackDefaults);
const deps = makeDeps({
loadRules: vi.fn(async () => [
{
...extractRule,
on_no_match: 'fallback_destination' as const,
fallback_inbound_ticket_defaults_id: 'defaults-triage',
},
]),
matchClientByName: vi.fn(async () => null),
resolveDefaultsById,
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(resolveDefaultsById).toHaveBeenCalledWith(TENANT, 'defaults-triage');
expect(result.outcome).toMatchObject({ kind: 'fallback_destination', defaults: fallbackDefaults });
});
it('a dangling fallback destination degrades to proceed', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
{
...extractRule,
on_no_match: 'fallback_destination' as const,
fallback_inbound_ticket_defaults_id: 'deleted-defaults',
},
]),
matchClientByName: vi.fn(async () => null),
resolveDefaultsById: vi.fn(async () => null),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace[0].resolution).toBe('dangling_reference');
});
it('malformed action_config degrades to proceed', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
makeRule({ id: 'bad', action_type: 'extract_assign_client', action_config: {} }),
]),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace[0].resolution).toBe('dangling_reference');
});
});
describe('inboundEmailRules engine: set_destination', () => {
it('returns the referenced defaults set', async () => {
const defaults = { board_id: 'security-board' };
const deps = makeDeps({
loadRules: vi.fn(async () => [
makeRule({
action_type: 'set_destination',
action_config: { inbound_ticket_defaults_id: 'defaults-security' },
}),
]),
resolveDefaultsById: vi.fn(async () => defaults),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toMatchObject({ kind: 'set_destination', defaults });
});
it('a dangling destination reference degrades to proceed', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
makeRule({
action_type: 'set_destination',
action_config: { inbound_ticket_defaults_id: 'deleted' },
}),
]),
resolveDefaultsById: vi.fn(async () => null),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace[0].resolution).toBe('dangling_reference');
});
});
describe('inboundEmailRules engine: ai_classify', () => {
const aiRule = makeRule({
id: 'ai-rule',
action_type: 'ai_classify',
action_config: {
instruction: 'Determine which customer this alert concerns.',
allowed_outcomes: ['skip', 'assign_client'],
},
});
it('honors a skip decision when allowed', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [aiRule]),
classifyWithAi: vi.fn(async () => ({ decision: 'skip' as const })),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toMatchObject({ kind: 'skip', ruleId: 'ai-rule', via: 'action' });
});
it('treats a decision outside allowed_outcomes as no_decision', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
{ ...aiRule, action_config: { instruction: 'x', allowed_outcomes: ['assign_client'] } },
]),
classifyWithAi: vi.fn(async () => ({ decision: 'skip' as const })),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace[0].aiDecision).toBe('no_decision');
});
it('resolves assign_client decisions through the deterministic matcher', async () => {
const matchClientByName = vi.fn(async () => ({ clientId: 'client-acme', matchedBy: 'alias' as const }));
const deps = makeDeps({
loadRules: vi.fn(async () => [aiRule]),
classifyWithAi: vi.fn(async () => ({
decision: 'assign_client' as const,
extractedClientName: ' Acme Corp ',
})),
matchClientByName,
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(matchClientByName).toHaveBeenCalledWith(TENANT, 'acme corp');
expect(result.outcome).toMatchObject({
kind: 'assign_client',
clientId: 'client-acme',
matchSource: 'rule_ai',
});
});
it('routes an unmatched AI client name to on_no_match', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [{ ...aiRule, on_no_match: 'skip' as const }]),
classifyWithAi: vi.fn(async () => ({
decision: 'assign_client' as const,
extractedClientName: 'Unknown Co',
})),
matchClientByName: vi.fn(async () => null),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toMatchObject({ kind: 'skip', via: 'on_no_match' });
});
it('treats AI errors as non-match without blocking', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [aiRule]),
classifyWithAi: vi.fn(async () => {
throw new Error('ai timeout');
}),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace[0].resolution).toBe('no_match_proceed');
});
it('treats a no_decision result as non-match (OSS stub behavior)', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [aiRule]),
classifyWithAi: vi.fn(async () => ({ decision: 'no_decision' as const })),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
});
});
describe('inboundEmailRules engine: error isolation', () => {
it('degrades to none when rule loading fails', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => {
throw new Error('db unavailable');
}),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
expect(result.trace.at(-1)?.resolution).toBe('error');
});
it('degrades to none when the matcher throws mid-walk', async () => {
const deps = makeDeps({
loadRules: vi.fn(async () => [
makeRule({
action_type: 'extract_assign_client',
action_config: { source: 'subject', extraction: { type: 'between', start: '(', end: ')' } },
}),
]),
matchClientByName: vi.fn(async () => {
throw new Error('query failed');
}),
});
const result = await evaluateInboundEmailRules({
tenantId: TENANT,
providerId: PROVIDER,
emailData: makeEmailData(),
deps,
});
expect(result.outcome).toEqual({ kind: 'none' });
});
});