PSA/shared/services/email/__tests__/inboundEmailRules.evaluator.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

268 lines
8.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
MAX_BODY_TEXT_LENGTH,
MAX_REGEX_PATTERN_LENGTH,
buildRuleEmailInput,
evaluateCondition,
evaluateConditions,
extractValue,
extractionToRegexSource,
normalizeExtractedValue,
} from '../inboundEmailRules/evaluator';
import type {
InboundEmailRuleCondition,
InboundEmailRuleEmailInput,
} from '../inboundEmailRules/types';
function makeInput(overrides: Partial<InboundEmailRuleEmailInput> = {}): InboundEmailRuleEmailInput {
return {
fromAddress: 'alerts@huntress.com',
fromDomain: 'huntress.com',
toAddresses: ['support@msp.com'],
subject: 'Critical Alert (Acme Corp) - EDR detection',
bodyText: 'Incident details follow.',
...overrides,
};
}
function condition(partial: Partial<InboundEmailRuleCondition>): InboundEmailRuleCondition {
return {
field: 'subject',
operator: 'contains',
value: '',
...partial,
};
}
describe('inboundEmailRules evaluator: conditions', () => {
it('equals matches case-insensitively', () => {
const input = makeInput({ subject: 'Status Update' });
expect(
evaluateCondition(condition({ operator: 'equals', value: 'status update' }), input)
).toBe(true);
expect(
evaluateCondition(condition({ operator: 'equals', value: 'status' }), input)
).toBe(false);
});
it('contains matches substring case-insensitively', () => {
const input = makeInput();
expect(
evaluateCondition(condition({ operator: 'contains', value: 'ACME CORP' }), input)
).toBe(true);
expect(
evaluateCondition(condition({ operator: 'contains', value: 'globex' }), input)
).toBe(false);
});
it('starts_with and ends_with match case-insensitively', () => {
const input = makeInput({ subject: 'Alert: disk full' });
expect(
evaluateCondition(condition({ operator: 'starts_with', value: 'alert:' }), input)
).toBe(true);
expect(
evaluateCondition(condition({ operator: 'ends_with', value: 'DISK FULL' }), input)
).toBe(true);
expect(
evaluateCondition(condition({ operator: 'starts_with', value: 'disk' }), input)
).toBe(false);
});
it('requires ALL conditions to match', () => {
const input = makeInput();
const { matched, results } = evaluateConditions(
[
condition({ field: 'from_address', operator: 'contains', value: '@huntress.com' }),
condition({ field: 'subject', operator: 'contains', value: 'no-such-text' }),
],
input
);
expect(matched).toBe(false);
expect(results.map((r) => r.passed)).toEqual([true, false]);
});
it('never matches an empty condition list', () => {
expect(evaluateConditions([], makeInput()).matched).toBe(false);
});
it('derives from_domain from the sender address', () => {
const input = buildRuleEmailInput({
from: { email: 'Alerts <ALERTS@Huntress.COM>' },
subject: 'x',
});
expect(input.fromDomain).toBe('huntress.com');
expect(
evaluateCondition(condition({ field: 'from_domain', operator: 'equals', value: 'huntress.com' }), input)
).toBe(true);
});
it('to_address matches when any recipient (to or cc) matches', () => {
const input = buildRuleEmailInput({
from: { email: 'a@b.com' },
to: [{ email: 'first@msp.com' }],
cc: [{ email: 'second@msp.com' }],
});
expect(
evaluateCondition(condition({ field: 'to_address', operator: 'equals', value: 'second@msp.com' }), input)
).toBe(true);
expect(
evaluateCondition(condition({ field: 'to_address', operator: 'equals', value: 'third@msp.com' }), input)
).toBe(false);
});
it('slices body_text to the cap before evaluation', () => {
const marker = 'NEEDLE-BEYOND-CAP';
const body = 'x'.repeat(MAX_BODY_TEXT_LENGTH + 100) + marker;
const input = buildRuleEmailInput({
from: { email: 'a@b.com' },
body: { text: body },
});
expect(input.bodyText.length).toBe(MAX_BODY_TEXT_LENGTH);
expect(
evaluateCondition(condition({ field: 'body_text', operator: 'contains', value: marker }), input)
).toBe(false);
});
it('matches_regex evaluates a valid pattern case-insensitively', () => {
const input = makeInput({ subject: 'Ticket #4521 escalated' });
expect(
evaluateCondition(condition({ operator: 'matches_regex', value: 'ticket #\\d+' }), input)
).toBe(true);
});
it('treats an invalid regex as non-matching without throwing', () => {
const input = makeInput();
expect(
evaluateCondition(condition({ operator: 'matches_regex', value: '([unclosed' }), input)
).toBe(false);
});
it('rejects patterns over the length cap', () => {
const input = makeInput({ subject: 'aaa' });
const oversized = 'a'.repeat(MAX_REGEX_PATTERN_LENGTH + 1);
expect(
evaluateCondition(condition({ operator: 'matches_regex', value: oversized }), input)
).toBe(false);
});
});
describe('inboundEmailRules evaluator: extraction', () => {
const subjectInput = makeInput({ subject: 'Critical Alert (Acme Corp) - EDR detection' });
it('between extracts text inside delimiters', () => {
const value = extractValue(
{ source: 'subject', extraction: { type: 'between', start: '(', end: ')' } },
subjectInput
);
expect(value).toBe('Acme Corp');
});
it('between with a missing end delimiter is a non-match', () => {
const value = extractValue(
{ source: 'subject', extraction: { type: 'between', start: '[', end: ']' } },
subjectInput
);
expect(value).toBeNull();
});
it('between honors occurrence first vs last', () => {
const input = makeInput({ subject: 'Alert (Acme Corp) resolved (Globex Inc)' });
expect(
extractValue(
{ source: 'subject', extraction: { type: 'between', start: '(', end: ')', occurrence: 'first' } },
input
)
).toBe('Acme Corp');
expect(
extractValue(
{ source: 'subject', extraction: { type: 'between', start: '(', end: ')', occurrence: 'last' } },
input
)
).toBe('Globex Inc');
});
it('after extracts text following the marker to end of line', () => {
const input = makeInput({ bodyText: 'Severity: high\nCustomer: Acme Corp\nDevice: srv-01' });
expect(
extractValue(
{ source: 'body_text', extraction: { type: 'after', marker: 'Customer:' } },
input
)
).toBe('Acme Corp');
});
it('before extracts text preceding the marker', () => {
const input = makeInput({ subject: 'Acme Corp - alert escalated' });
expect(
extractValue(
{ source: 'subject', extraction: { type: 'before', marker: '- alert' } },
input
)
).toBe('Acme Corp');
});
it('regex extraction returns capture group 1', () => {
expect(
extractValue(
{ source: 'subject', extraction: { type: 'regex', pattern: 'Alert \\(([^)]+)\\)' } },
subjectInput
)
).toBe('Acme Corp');
});
it('regex extraction without a capture group is a non-match', () => {
expect(
extractValue(
{ source: 'subject', extraction: { type: 'regex', pattern: 'Alert' } },
subjectInput
)
).toBeNull();
});
it('friendly templates compile to regex (single code path)', () => {
const templateSource = extractionToRegexSource({ type: 'between', start: '(', end: ')' });
expect(templateSource).toBeTruthy();
const viaTemplate = extractValue(
{ source: 'subject', extraction: { type: 'between', start: '(', end: ')' } },
subjectInput
);
const viaRegex = extractValue(
{ source: 'subject', extraction: { type: 'regex', pattern: templateSource! } },
subjectInput
);
expect(viaTemplate).toBe(viaRegex);
});
it('handles unicode client names', () => {
const input = makeInput({ subject: 'Alerte (Café Société) — détection' });
expect(
extractValue(
{ source: 'subject', extraction: { type: 'between', start: '(', end: ')' } },
input
)
).toBe('Café Société');
});
it('extraction from an empty source is a non-match', () => {
const input = makeInput({ subject: '' });
expect(
extractValue(
{ source: 'subject', extraction: { type: 'between', start: '(', end: ')' } },
input
)
).toBeNull();
});
});
describe('inboundEmailRules evaluator: normalization', () => {
it('trims, collapses whitespace, and lowercases', () => {
expect(normalizeExtractedValue(' Acme Corp ')).toBe('acme corp');
});
it('returns an empty string for empty/whitespace-only values', () => {
expect(normalizeExtractedValue(null)).toBe('');
expect(normalizeExtractedValue(undefined)).toBe('');
expect(normalizeExtractedValue(' ')).toBe('');
});
});