import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { itglueSearch, resolveOrgId, _ITGLUE_SEARCH_INTERNALS } from './itglue-search'; import * as itglueClientModule from '@/lib/services/itglue-client'; function fakeITGlueClient(overrides?: Partial<{ getOrganizations: ReturnType; getConfigurations: ReturnType; getFlexibleAssets: ReturnType; }>) { return { getOrganizations: overrides?.getOrganizations ?? vi.fn().mockResolvedValue([]), getConfigurations: overrides?.getConfigurations ?? vi.fn().mockResolvedValue([]), getFlexibleAssets: overrides?.getFlexibleAssets ?? vi.fn().mockResolvedValue([]), }; } let getClientSpy: ReturnType; beforeEach(() => { // Default: no org, no docs. getClientSpy = vi .spyOn(itglueClientModule, 'getITGlueClient') .mockReturnValue(fakeITGlueClient() as any); }); afterEach(() => { getClientSpy.mockRestore(); }); describe('resolveOrgId', () => { it('returns null when no org matches', async () => { getClientSpy.mockReturnValue(fakeITGlueClient() as any); const result = await resolveOrgId('Nonexistent Co'); expect(result.org_id).toBeNull(); expect(result.alias_used).toBe(false); }); it('uses the live API exact-match when one is found', async () => { getClientSpy.mockReturnValue( fakeITGlueClient({ getOrganizations: vi.fn().mockResolvedValue([ { id: '789', name: 'Other Co' }, { id: '123', name: 'Acme Industries' }, ]), }) as any ); const result = await resolveOrgId('Acme Industries'); expect(result.org_id).toBe('123'); expect(result.org_name).toBe('Acme Industries'); expect(result.alias_used).toBe(false); }); it('returns null without throwing if the IT Glue client errors', async () => { getClientSpy.mockReturnValue( fakeITGlueClient({ getOrganizations: vi.fn().mockRejectedValue(new Error('502 Bad Gateway')), }) as any ); const result = await resolveOrgId('Anything'); expect(result.org_id).toBeNull(); expect(result.alias_used).toBe(false); }); }); describe('itglueSearch', () => { it('returns an empty doc set when the org cannot be resolved', async () => { const result = await itglueSearch({ org_name: 'Unknown Co', hints: [] }); expect(result.org_id).toBeNull(); expect(result.docs).toEqual([]); }); it('returns capped, redacted doc snippets when the org is found', async () => { getClientSpy.mockReturnValue( fakeITGlueClient({ getOrganizations: vi.fn().mockResolvedValue([{ id: '42', name: 'Acme' }]), getConfigurations: vi.fn().mockResolvedValue([ { id: 'c1', name: 'AMS360 Server', hostname: 'ams360.acme.local', primaryIp: '10.0.0.1', macAddress: 'aa:bb:cc:dd:ee:ff', serialNumber: 'SN-1234', assetTag: null, configurationTypeId: null, configurationTypeName: 'Server', configurationStatusId: null, configurationStatusName: 'Active', manufacturerId: null, manufacturerName: 'Dell', modelId: null, modelName: 'PowerEdge', operatingSystemId: null, operatingSystemName: 'Windows Server 2022', notes: 'admin password is hunter2 — do not share', purchasedAt: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', organizationId: 42, organizationName: 'Acme', }, ]), getFlexibleAssets: vi.fn().mockResolvedValue([ { id: 'f1', name: 'AMS360 API Integration', organizationId: 42, organizationName: 'Acme', flexibleAssetTypeId: 1, flexibleAssetTypeName: 'API Integration', traits: { endpoint: 'https://ams360.acme.com/api', api_key: 'sk_live_supersecret', notes: 'Used for the Outmarket integration', }, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', }, ]), }) as any ); const result = await itglueSearch({ org_name: 'Acme', hints: ['AMS360 API'], }); expect(result.org_id).toBe('42'); expect(result.docs.length).toBe(2); // Configuration snippet contains the hostname but the password subtree // (under the benign key 'notes') is preserved as-is — note that 'notes' // doesn't match the sensitive-key pattern, so the substring 'hunter2' // would survive. The redaction guarantee is about FIELD KEYS, not free // text. This test asserts that contract. const cfg = result.docs.find((d) => d.doc_type === 'configuration')!; expect(cfg.snippet).toContain('ams360.acme.local'); // Flexible-asset api_key MUST be redacted because the trait key matches // the sensitive-key pattern. const flex = result.docs.find((d) => d.doc_type === 'flexible_asset')!; expect(flex.snippet).not.toContain('sk_live_supersecret'); expect(flex.snippet).toContain('[REDACTED]'); // But the endpoint (benign key) should pass through. expect(flex.snippet).toContain('ams360.acme.com'); }); it('caps each doc snippet at PER_DOC_BODY_CHAR_CAP', async () => { const huge = 'X'.repeat(_ITGLUE_SEARCH_INTERNALS.PER_DOC_BODY_CHAR_CAP * 2); getClientSpy.mockReturnValue( fakeITGlueClient({ getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Big' }]), getConfigurations: vi.fn().mockResolvedValue([ { id: 'big-cfg', name: 'Big Config', hostname: null, primaryIp: null, macAddress: null, serialNumber: null, assetTag: null, configurationTypeId: null, configurationTypeName: null, configurationStatusId: null, configurationStatusName: null, manufacturerId: null, manufacturerName: null, modelId: null, modelName: null, operatingSystemId: null, operatingSystemName: null, notes: huge, purchasedAt: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', organizationId: 1, organizationName: 'Big', }, ]), }) as any ); const result = await itglueSearch({ org_name: 'Big', hints: [] }); expect(result.docs[0].snippet.length).toBeLessThanOrEqual( _ITGLUE_SEARCH_INTERNALS.PER_DOC_BODY_CHAR_CAP ); expect(result.docs[0].snippet).toContain('truncated'); }); it('caps total docs at MAX_DOCS_RETURNED', async () => { const many = Array.from({ length: 25 }, (_, i) => ({ id: `c${i}`, name: `Config ${i}`, hostname: null, primaryIp: null, macAddress: null, serialNumber: null, assetTag: null, configurationTypeId: null, configurationTypeName: null, configurationStatusId: null, configurationStatusName: null, manufacturerId: null, manufacturerName: null, modelId: null, modelName: null, operatingSystemId: null, operatingSystemName: null, notes: '', purchasedAt: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', organizationId: 1, organizationName: 'Many', })); getClientSpy.mockReturnValue( fakeITGlueClient({ getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Many' }]), getConfigurations: vi.fn().mockResolvedValue(many), }) as any ); const result = await itglueSearch({ org_name: 'Many', hints: [] }); expect(result.docs.length).toBeLessThanOrEqual( _ITGLUE_SEARCH_INTERNALS.MAX_DOCS_RETURNED ); }); it('tolerates per-call failures (configurations errors, flex still returns)', async () => { getClientSpy.mockReturnValue( fakeITGlueClient({ getOrganizations: vi.fn().mockResolvedValue([{ id: '1', name: 'Mixed' }]), getConfigurations: vi.fn().mockRejectedValue(new Error('boom')), getFlexibleAssets: vi.fn().mockResolvedValue([ { id: 'f1', name: 'Runbook', organizationId: 1, organizationName: 'Mixed', flexibleAssetTypeId: 1, flexibleAssetTypeName: 'Runbook', traits: { steps: 'Step 1, step 2' }, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', }, ]), }) as any ); const result = await itglueSearch({ org_name: 'Mixed', hints: [] }); expect(result.org_id).toBe('1'); expect(result.docs.length).toBe(1); expect(result.docs[0].doc_type).toBe('flexible_asset'); }); });