UNPKG

@accounter/server

Version:
499 lines (429 loc) 19.2 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { DBProvider } from '../../app-providers/db.provider.js'; import { emailMatchesPattern } from '../../financial-entities/helpers/email-pattern.helper.js'; import { EmailKind, type EmailClassification, } from '../helpers/email-ingestion-classify.helper.js'; import { EmailIngestionControlProvider } from '../providers/email-ingestion-control.provider.js'; // --------------------------------------------------------------------------- // Mock DB helpers // --------------------------------------------------------------------------- type MockQueryResult = { rows: Record<string, unknown>[]; rowCount: number }; function isControlStatement(text: string): boolean { return ( text === 'BEGIN' || text === 'COMMIT' || text === 'ROLLBACK' || text.includes('set_config') ); } function makeDbProvider( queryImpl: (text: string, params?: unknown[]) => MockQueryResult, ): DBProvider { // resolveAlias runs on the raw pool; the grant-table ops (issueGrant / // validateAndConsumeGrant) run on a pooled client inside a transaction. One // shared mock backs both pool.query and the connected client.query: control // statements (BEGIN / SET LOCAL / COMMIT / ROLLBACK) are served transparently // and data queries delegate to queryImpl, so every query is recorded in one // place. const query = vi.fn().mockImplementation((text: unknown, params?: unknown[]) => { const sqlText = typeof text === 'string' ? text : ''; if (isControlStatement(sqlText)) { return { rows: [], rowCount: 0 }; } return queryImpl(sqlText, params); }); const release = vi.fn(); return { pool: { query, connect: vi.fn().mockResolvedValue({ query, release }), }, } as unknown as DBProvider; } // --------------------------------------------------------------------------- // resolveAlias // --------------------------------------------------------------------------- describe('EmailIngestionControlProvider.resolveAlias', () => { it('returns found=true with tenantId for a known active alias', async () => { const db = makeDbProvider(() => ({ rows: [{ owner_id: 'tenant-uuid-1' }], rowCount: 1, })); const provider = new EmailIngestionControlProvider(db); const result = await provider.resolveAlias('invoice@tenant.example.com'); expect(result.found).toBe(true); if (result.found) { expect(result.tenantId).toBe('tenant-uuid-1'); } }); it('returns found=false with UNKNOWN_ALIAS reason when alias is not in table', async () => { const db = makeDbProvider(() => ({ rows: [], rowCount: 0 })); const provider = new EmailIngestionControlProvider(db); const result = await provider.resolveAlias('notfound@example.com'); expect(result.found).toBe(false); if (!result.found) { expect(result.reason).toBe('UNKNOWN_ALIAS'); } }); it('returns found=false for an inactive alias (query filters is_active=TRUE)', async () => { // The provider queries with is_active = TRUE, so an inactive alias returns 0 rows. const db = makeDbProvider(() => ({ rows: [], rowCount: 0 })); const provider = new EmailIngestionControlProvider(db); const result = await provider.resolveAlias('inactive@example.com'); expect(result.found).toBe(false); if (!result.found) { expect(result.reason).toBe('UNKNOWN_ALIAS'); } }); it('queries with lowercased alias and active flag', async () => { const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }); const db = { pool: { query } } as unknown as DBProvider; const provider = new EmailIngestionControlProvider(db); await provider.resolveAlias('Mixed@Case.Example.COM'); const [sql, params] = query.mock.calls[0] as [string, unknown[]]; expect(sql.toLowerCase()).toMatch(/is_active/); expect(params[0]).toBe('mixed@case.example.com'); }); }); // --------------------------------------------------------------------------- // issueGrant // --------------------------------------------------------------------------- describe('EmailIngestionControlProvider.issueGrant', () => { const grantInput = { tenantId: 'tenant-uuid-1', messageId: 'msg-abc-123', rawMessageHash: 'sha256-abc', expiresAt: new Date('2026-06-11T00:05:00Z'), correlationId: 'corr-xyz', }; let provider: EmailIngestionControlProvider; let mockDb: DBProvider; beforeEach(() => { mockDb = makeDbProvider(() => ({ rows: [ { id: 'grant-row-uuid', jti: 'jti-value', owner_id: 'tenant-uuid-1', action: 'ingest', expires_at: new Date('2026-06-11T00:05:00Z'), }, ], rowCount: 1, })); provider = new EmailIngestionControlProvider(mockDb); }); it('returns a grant with jti and tenantId', async () => { const grant = await provider.issueGrant(grantInput); expect(typeof grant.jti).toBe('string'); expect(grant.jti.length).toBeGreaterThan(0); expect(grant.tenantId).toBe('tenant-uuid-1'); }); it('returns a grant with messageId and rawMessageHash matching input', async () => { const grant = await provider.issueGrant(grantInput); expect(grant.messageId).toBe('msg-abc-123'); expect(grant.rawMessageHash).toBe('sha256-abc'); }); it('returns a grant with action=ingest and valid expiresAt', async () => { const grant = await provider.issueGrant(grantInput); expect(grant.action).toBe('ingest'); expect(grant.expiresAt).toBeInstanceOf(Date); }); it('includes decisionId and auditId as non-empty strings', async () => { const grant = await provider.issueGrant(grantInput); expect(typeof grant.decisionId).toBe('string'); expect(grant.decisionId.length).toBeGreaterThan(0); expect(typeof grant.auditId).toBe('string'); expect(grant.auditId.length).toBeGreaterThan(0); }); it('decisionId and auditId are distinct', async () => { const grant = await provider.issueGrant(grantInput); expect(grant.decisionId).not.toBe(grant.auditId); }); it('inserts into the grants table with correct tenant binding', async () => { await provider.issueGrant(grantInput); const query = mockDb.pool.query as ReturnType<typeof vi.fn>; // The INSERT runs on the pooled client among BEGIN / SET LOCAL / COMMIT, so // locate it rather than assuming a fixed call index. const insertCall = query.mock.calls.find( ([sql]) => typeof sql === 'string' && /insert.*email_ingestion_grants/s.test(sql.toLowerCase()), ) as [string, unknown[]] | undefined; expect(insertCall).toBeDefined(); const params = insertCall![1]; expect(params).toContain('tenant-uuid-1'); expect(params).toContain('msg-abc-123'); }); it('persists the recognized business_id when provided', async () => { await provider.issueGrant({ ...grantInput, businessId: 'biz-uuid-9' }); const query = mockDb.pool.query as ReturnType<typeof vi.fn>; const insertCall = query.mock.calls.find( ([sql]) => typeof sql === 'string' && /insert.*email_ingestion_grants/s.test(sql.toLowerCase()), ) as [string, unknown[]] | undefined; expect(insertCall).toBeDefined(); expect(insertCall![1]).toContain('biz-uuid-9'); }); it('persists null business_id when none was recognized', async () => { await provider.issueGrant(grantInput); const query = mockDb.pool.query as ReturnType<typeof vi.fn>; const insertCall = query.mock.calls.find( ([sql]) => typeof sql === 'string' && /insert.*email_ingestion_grants/s.test(sql.toLowerCase()), ) as [string, unknown[]] | undefined; expect(insertCall).toBeDefined(); expect(insertCall![1]).toContain(null); }); }); // --------------------------------------------------------------------------- // recognizeBusiness // --------------------------------------------------------------------------- describe('EmailIngestionControlProvider.recognizeBusiness', () => { it('returns the businessId and emailListener config for a matched business', async () => { const db = makeDbProvider(sql => { if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) { return { rows: [ { id: 'biz-1', suggestion_data: { emails: ['vendor@acme.com'], emailListener: { emailBody: true, attachments: ['PDF'], internalEmailLinks: ['https://acme.com/inv'], }, }, }, ], rowCount: 1, }; } return { rows: [], rowCount: 0 }; }); const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusiness('tenant-1', 'vendor@acme.com'); expect(result.businessId).toBe('biz-1'); expect(result.config).toEqual({ emailBody: true, attachments: ['PDF'], internalEmailLinks: ['https://acme.com/inv'], }); }); it('returns null businessId and empty config when no business matches', async () => { const db = makeDbProvider(() => ({ rows: [], rowCount: 0 })); const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusiness('tenant-1', 'nobody@nowhere.com'); expect(result.businessId).toBeNull(); expect(result.config).toEqual({}); }); it('short-circuits without touching the DB when no issuer email is given', async () => { const query = vi.fn(); const connect = vi.fn(); const db = { pool: { query, connect } } as unknown as DBProvider; const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusiness('tenant-1', null); expect(result).toEqual({ businessId: null, config: {} }); expect(query).not.toHaveBeenCalled(); expect(connect).not.toHaveBeenCalled(); }); it('returns the businessId but empty config when suggestion_data is invalid', async () => { const db = makeDbProvider(sql => { if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) { return { rows: [{ id: 'biz-2', suggestion_data: { unexpected: 'shape' } }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; }); const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusiness('tenant-1', 'vendor@acme.com'); expect(result.businessId).toBe('biz-2'); expect(result.config).toEqual({}); }); it('pins the lookup to the tenant RLS context (set_config with the tenant id)', async () => { const db = makeDbProvider(() => ({ rows: [{ id: 'biz-3', suggestion_data: null }], rowCount: 1, })); const provider = new EmailIngestionControlProvider(db); await provider.recognizeBusiness('tenant-xyz', 'vendor@acme.com'); const query = db.pool.query as ReturnType<typeof vi.fn>; const setConfigCall = query.mock.calls.find( ([sql]) => typeof sql === 'string' && sql.includes('set_config'), ) as [string, unknown[]] | undefined; expect(setConfigCall).toBeDefined(); expect(setConfigCall![1]).toContain('tenant-xyz'); }); }); // --------------------------------------------------------------------------- // recognizeBusinessFromClassification // --------------------------------------------------------------------------- describe('EmailIngestionControlProvider.recognizeBusinessFromClassification', () => { /** Build a classification carrying just the candidate list under test. */ function candidates(issuerCandidates: string[]): EmailClassification { return { kind: EmailKind.DIRECT, issuerCandidates, forwarder: null, issuerNameHint: null }; } // Mock the businesses lookup to match a single email (case-insensitively), // mirroring the real lower()-based SQL. function dbMatchingEmail(target: string, row: Record<string, unknown>) { return makeDbProvider((sql, params) => { if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) { const email = (params?.[0] as string | undefined)?.toLowerCase(); if (email === target.toLowerCase()) { return { rows: [row], rowCount: 1 }; } } return { rows: [], rowCount: 0 }; }); } it('recognizes the issuer from a forwarded body candidate when the live From is the forwarder', async () => { const db = dbMatchingEmail('noreply@notify.cloudflare.com', { id: 'cloudflare-biz', suggestion_data: { emails: ['noreply@notify.cloudflare.com'], emailListener: { emailBody: false, attachments: ['PDF'] }, }, }); const provider = new EmailIngestionControlProvider(db); // The classifier has already dropped the forwarder and the tenant's own group, // so only the real issuer reaches the lookup. const result = await provider.recognizeBusinessFromClassification( 'tenant-1', candidates(['noreply@notify.cloudflare.com']), ); expect(result.businessId).toBe('cloudflare-biz'); expect(result.config).toEqual({ emailBody: false, attachments: ['PDF'] }); }); it('recognizes a business via a wildcard suggestion_data email (unique per-invoice sender)', async () => { // The real SQL translates a stored `*@cloudflare.com` pattern into a LIKE // match; the mock mirrors that using the shared in-process matcher so the // wildcard is what actually drives recognition here. const business = { id: 'cloudflare-biz', suggestion_data: { emails: ['*@cloudflare.com'], emailListener: { emailBody: false, attachments: ['PDF'] }, }, }; const db = makeDbProvider((sql, params) => { if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) { const email = params?.[0] as string | undefined; if (email && business.suggestion_data.emails.some(p => emailMatchesPattern(p, email))) { return { rows: [business], rowCount: 1 }; } } return { rows: [], rowCount: 0 }; }); const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusinessFromClassification( 'tenant-1', candidates(['qr45uf@cloudflare.com']), ); expect(result.businessId).toBe('cloudflare-biz'); expect(result.config).toEqual({ emailBody: false, attachments: ['PDF'] }); }); it('matches case-insensitively', async () => { const db = dbMatchingEmail('vendor@acme.com', { id: 'biz-1', suggestion_data: { emails: ['vendor@acme.com'] }, }); const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusinessFromClassification( 'tenant-1', candidates(['vendor@acme.com']), ); expect(result.businessId).toBe('biz-1'); }); it('returns null when no candidate matches a business', async () => { const db = makeDbProvider(() => ({ rows: [], rowCount: 0 })); const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusinessFromClassification( 'tenant-1', candidates(['nobody@nowhere.com']), ); expect(result).toEqual({ businessId: null, config: {} }); }); it('short-circuits without touching the DB when there is no usable evidence', async () => { const query = vi.fn(); const connect = vi.fn(); const db = { pool: { query, connect } } as unknown as DBProvider; const provider = new EmailIngestionControlProvider(db); const result = await provider.recognizeBusinessFromClassification('tenant-1', candidates([])); expect(result).toEqual({ businessId: null, config: {} }); expect(query).not.toHaveBeenCalled(); expect(connect).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // loadTenantMailContext // --------------------------------------------------------------------------- describe('EmailIngestionControlProvider.loadTenantMailContext', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); /** Serves the two reads the loader issues: active aliases, then the own business row. */ function makeContextDb(ownBusiness: Record<string, unknown> | null = null) { return makeDbProvider(sql => { if (sql.includes('email_ingestion_alias_routing')) { return { rows: [{ alias: 'tenant-alias@accounter.tax' }], rowCount: 1 }; } if (sql.includes('accounter_schema.businesses')) { return ownBusiness ? { rows: [ownBusiness], rowCount: 1 } : { rows: [], rowCount: 0 }; } return { rows: [], rowCount: 0 }; }); } it('derives own addresses, names, domains and platform senders', async () => { const db = makeContextDb({ name: 'Acme Ltd', hebrew_name: 'אקמה', suggestion_data: { emails: ['billing@tenant.example'], emailIngestion: { ownDomains: ['Tenant.Example'], extraPlatformSenders: ['relay@platform.example'], }, }, }); const provider = new EmailIngestionControlProvider(db); const ctx = await provider.loadTenantMailContext('tenant-1'); expect([...ctx.ownAddresses].sort()).toEqual([ 'billing@tenant.example', 'tenant-alias@accounter.tax', ]); expect([...ctx.ownDomains]).toEqual(['tenant.example']); expect(ctx.ownNames).toEqual(['Acme Ltd', 'אקמה']); expect(ctx.invoicePlatformSenders.has('relay@platform.example')).toBe(true); expect(ctx.invoicePlatformSenders.has('notify@morning.co')).toBe(true); }); it('caches within the TTL and refreshes after it', async () => { const db = makeContextDb(); const provider = new EmailIngestionControlProvider(db); const query = (db.pool as unknown as { query: ReturnType<typeof vi.fn> }).query; await provider.loadTenantMailContext('tenant-1'); const afterFirst = query.mock.calls.length; await provider.loadTenantMailContext('tenant-1'); expect(query.mock.calls.length).toBe(afterFirst); // served from cache vi.advanceTimersByTime(61_000); await provider.loadTenantMailContext('tenant-1'); expect(query.mock.calls.length).toBeGreaterThan(afterFirst); }); // The provider is a process-lifetime singleton, so without a sweep the cache would // retain an entry for every tenant that ever received mail. it('evicts expired entries so the cache cannot grow without bound', async () => { const db = makeContextDb(); const provider = new EmailIngestionControlProvider(db); const cache = ( provider as unknown as { mailContextCache: Map<string, unknown> } ).mailContextCache; for (const tenant of ['tenant-1', 'tenant-2', 'tenant-3']) { await provider.loadTenantMailContext(tenant); } expect(cache.size).toBe(3); // Every existing entry is now stale; loading a fourth tenant sweeps them out. vi.advanceTimersByTime(61_000); await provider.loadTenantMailContext('tenant-4'); expect([...cache.keys()]).toEqual(['tenant-4']); }); });