feat(capability): per-scope BYO provider credentials + Twilio SMS egress
IIOS becomes the tenant's integration/credential hub for anything it sends. New IiosProviderCredential (one row per scope+providerType) seals the secret with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret displayHints are ever read back. ProviderCredentialService upsert/resolve/status; TwilioSmsProvider resolves the caller's own creds per scope at send time and POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the SMS sandbox only when the platform key + store are present. PUT/GET /v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { CapabilityRequest } from '@insignia/iios-contracts';
|
||||
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
|
||||
|
||||
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
|
||||
|
||||
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
|
||||
capability: 'channel.send',
|
||||
channelType: 'SMS',
|
||||
scopeId: 'scope_1',
|
||||
target: '+15557654321',
|
||||
payload: { text: 'hello world' },
|
||||
idempotencyKey: 'idem-1',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('TwilioSmsProvider', () => {
|
||||
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
|
||||
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
|
||||
const resolve = vi.fn().mockResolvedValue(CREDS);
|
||||
const p = new TwilioSmsProvider(resolve, http);
|
||||
|
||||
const res = await p.send(req());
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith('scope_1');
|
||||
const [url, init] = http.mock.calls[0];
|
||||
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('To')).toBe('+15557654321');
|
||||
expect(body.get('From')).toBe('+15550001111');
|
||||
expect(body.get('Body')).toBe('hello world');
|
||||
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
|
||||
});
|
||||
|
||||
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
|
||||
const http = vi.fn();
|
||||
const p = new TwilioSmsProvider(async () => null, http);
|
||||
const res = await p.send(req());
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||
expect(http).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
|
||||
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
|
||||
const res = await p.send(req({ scopeId: undefined }));
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||
});
|
||||
|
||||
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
|
||||
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
|
||||
const p = new TwilioSmsProvider(async () => CREDS, http);
|
||||
const res = await p.send(req());
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('TWILIO_20003');
|
||||
});
|
||||
|
||||
it('surfaces a transport throw as FAILED', async () => {
|
||||
const http = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
const p = new TwilioSmsProvider(async () => CREDS, http);
|
||||
const res = await p.send(req());
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user