import { DataSource } from 'typeorm'; import { PluginInstance } from './entities/plugin-instance.entity'; import { PluginInstanceService, InstanceExistsError } from './plugin-instance.service'; import { AddIntegrationFabric1781900000000 } from '../../database/migrations/1781900000000-AddIntegrationFabric'; import type { PluginConfigSchema } from '../../core/plugins/plugin.interfaces'; describe('PluginInstanceService', () => { let ds: DataSource; let service: PluginInstanceService; beforeEach(async () => { ds = new DataSource({ type: 'better-sqlite3', database: ':memory:', entities: [PluginInstance], migrations: [] }); await ds.initialize(); const runner = ds.createQueryRunner(); await new AddIntegrationFabric1781900000000().up(runner); await runner.release(); service = new PluginInstanceService(ds.getRepository(PluginInstance)); }); afterEach(async () => { if (ds.isInitialized) await ds.destroy(); }); it('mints a 64-hex-char secret and stores a composite id', async () => { const inst = await service.mint('chatwoot', 'acct1', { sessionScope: 'sess-1' }); expect(inst.id).toBe('chatwoot:acct1'); expect(inst.secret).toMatch(/^[0-9a-f]{64}$/); }); it('masks the secret on the operator-facing view', async () => { const inst = await service.mint('chatwoot', 'acct1', {}); expect(service.maskedView(inst).secret).toBe('***'); }); it('masks secret:true config fields (apiToken) on masked reads', () => { const schema = { type: 'object', properties: { apiToken: { type: 'string', secret: true }, accountId: { type: 'number' } }, } as PluginConfigSchema; const inst = { id: 'p:i', secret: 'x', config: { apiToken: 'live-token', accountId: 3 }, } as unknown as PluginInstance; const masked = service.maskedView(inst, schema); const config = masked.config as Record; expect(masked.secret).toBe('***'); expect(config.apiToken).toBe('***'); expect(config.accountId).toBe(3); }); it('masks the ENTIRE config when the schema is unavailable, e.g. the plugin is unloaded (fail-closed)', () => { const inst = { id: 'p:i', secret: 'x', config: { apiToken: 'live-token', accountId: 3 }, } as unknown as PluginInstance; const config = service.maskedView(inst, undefined).config as Record; expect(config.apiToken).toBe('***'); expect(config.accountId).toBe('***'); }); it('resolves an existing instance and returns null for an unknown one', async () => { await service.mint('chatwoot', 'acct1', {}); expect((await service.resolve('chatwoot', 'acct1'))?.id).toBe('chatwoot:acct1'); expect(await service.resolve('chatwoot', 'nope')).toBeNull(); }); it('accepts a valid operator secret, rejects short/empty, else auto-generates', async () => { const ok = await service.create('chatwoot-adapter', 'a1', { secret: 'cw-secret-16chars!' }); expect(ok.secret).toBe('cw-secret-16chars!'); await expect(service.create('chatwoot-adapter', 'a2', { secret: ' ' })).rejects.toThrow(/secret/i); await expect(service.create('chatwoot-adapter', 'a3', { secret: 'short' })).rejects.toThrow(/16/); const gen = await service.create('chatwoot-adapter', 'a4', {}); expect(gen.secret).toMatch(/^[0-9a-f]{64}$/); }); it('masks a NESTED secret:true config field on masked reads (recursive redaction)', () => { const schema = { type: 'object', properties: { provider: { type: 'object', properties: { apiToken: { type: 'string', secret: true }, region: { type: 'string' } }, }, }, } as PluginConfigSchema; const inst = { id: 'p:i', secret: 'x', config: { provider: { apiToken: 'live-token', region: 'us' } }, } as unknown as PluginInstance; const config = service.maskedView(inst, schema).config as { provider: Record }; expect(config.provider.apiToken).toBe('***'); expect(config.provider.region).toBe('us'); }); it('update restores a masked (sentinel) secret to the stored value instead of persisting "***"', async () => { const schema = { type: 'object', properties: { apiToken: { type: 'string', secret: true }, accountId: { type: 'number' } }, } as PluginConfigSchema; await service.create('chatwoot', 'acct1', { config: { apiToken: 'real-token', accountId: 1 } }); // Dashboard round-trips the masked config back with an edited non-secret field. const updated = await service.update('chatwoot', 'acct1', { config: { apiToken: '***', accountId: 2 } }, schema); expect(updated?.config).toEqual({ apiToken: 'real-token', accountId: 2 }); }); }); describe('PluginInstanceService provisioning', () => { let ds: DataSource; let service: PluginInstanceService; beforeEach(async () => { ds = new DataSource({ type: 'better-sqlite3', database: ':memory:', entities: [PluginInstance], migrations: [] }); await ds.initialize(); const runner = ds.createQueryRunner(); await new AddIntegrationFabric1781900000000().up(runner); await runner.release(); service = new PluginInstanceService(ds.getRepository(PluginInstance)); }); afterEach(async () => { if (ds.isInitialized) await ds.destroy(); }); it('create mints a new instance and rejects a duplicate with InstanceExistsError', async () => { const inst = await service.create('chatwoot', 'acct1', { sessionScope: 'sess-1' }); expect(inst.id).toBe('chatwoot:acct1'); expect(inst.secret).toMatch(/^[0-9a-f]{64}$/); await expect(service.create('chatwoot', 'acct1', {})).rejects.toBeInstanceOf(InstanceExistsError); }); it('list returns all instances for a plugin', async () => { await service.create('chatwoot', 'acct1', {}); await service.create('chatwoot', 'acct2', {}); await service.create('other', 'x', {}); const list = await service.list('chatwoot'); expect(list.map(i => i.instanceId).sort()).toEqual(['acct1', 'acct2']); }); it('regenerateSecret replaces the secret with a new value', async () => { const created = await service.create('chatwoot', 'acct1', {}); const rotated = await service.regenerateSecret('chatwoot', 'acct1'); expect(rotated.secret).toMatch(/^[0-9a-f]{64}$/); expect(rotated.secret).not.toBe(created.secret); }); it('setEnabled toggles enabled; update patches scope/config; remove deletes', async () => { await service.create('chatwoot', 'acct1', { sessionScope: 'a' }); expect((await service.setEnabled('chatwoot', 'acct1', false))?.enabled).toBe(false); const patched = await service.update('chatwoot', 'acct1', { sessionScope: 'b', config: { k: 1 } }); expect(patched?.sessionScope).toBe('b'); expect(patched?.config).toEqual({ k: 1 }); expect(await service.remove('chatwoot', 'acct1')).toBe(true); expect(await service.resolve('chatwoot', 'acct1')).toBeNull(); expect(await service.remove('chatwoot', 'acct1')).toBe(false); }); it('normalizes an empty sessionScope to null (all-sessions) on mint/create/update, never a literal ""', async () => { // '' would break outbound send (falsy sessionId) and be unrecoverable from the UI; store null instead. const minted = await service.mint('chatwoot', 'm1', { sessionScope: '' }); expect(minted.sessionScope).toBeNull(); const created = await service.create('chatwoot', 'acct1', { sessionScope: '' }); expect(created.sessionScope).toBeNull(); const patched = await service.update('chatwoot', 'acct1', { sessionScope: '' }); expect(patched?.sessionScope).toBeNull(); }); });