verify-phone-sms 0.9.129 → 0.9.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/test/identity.test.ts +281 -0
- package/test/sns.test.ts +150 -0
- package/test/verify-phone.test.ts +270 -0
package/package.json
CHANGED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import app from '../src/identity-verification-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The identity service fans out to three TrestleIQ endpoints (phone, person,
|
|
6
|
+
* location) and scores what comes back. `fetch` is stubbed per endpoint, so
|
|
7
|
+
* each test describes a world — "valid mobile, exact name match, active
|
|
8
|
+
* address" — and asserts the score, questions and recommendations that follow.
|
|
9
|
+
*/
|
|
10
|
+
type Json = Record<string, unknown>;
|
|
11
|
+
type World = { phone?: Json | number; person?: Json | number; location?: Json | number };
|
|
12
|
+
|
|
13
|
+
const BODY = {
|
|
14
|
+
phone_number: '2069735100',
|
|
15
|
+
legal_name: 'John Smith',
|
|
16
|
+
current_address: {
|
|
17
|
+
street_line_1: '123 Main St',
|
|
18
|
+
city: 'Seattle',
|
|
19
|
+
state_code: 'WA',
|
|
20
|
+
postal_code: '98101',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
25
|
+
let envKey: string | undefined;
|
|
26
|
+
|
|
27
|
+
function world(w: World) {
|
|
28
|
+
fetchMock.mockImplementation(async (input: string) => {
|
|
29
|
+
const path = new URL(input).pathname;
|
|
30
|
+
const pick = path.endsWith('/phone') ? w.phone : path.endsWith('/person') ? w.person : w.location;
|
|
31
|
+
if (typeof pick === 'number') return new Response('err', { status: pick, statusText: 'Boom' });
|
|
32
|
+
return new Response(JSON.stringify(pick ?? {}), { status: 200 });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const verify = (body: unknown = BODY) =>
|
|
37
|
+
app.request('/verify-identity', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json' },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
fetchMock = vi.fn();
|
|
45
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
46
|
+
envKey = process.env.TRESTLE_API_KEY;
|
|
47
|
+
process.env.TRESTLE_API_KEY = 'trestle-key';
|
|
48
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
49
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllGlobals();
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
if (envKey === undefined) delete process.env.TRESTLE_API_KEY;
|
|
56
|
+
else process.env.TRESTLE_API_KEY = envKey;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const GOOD_PHONE = {
|
|
60
|
+
is_valid: true,
|
|
61
|
+
line_type: 'Mobile',
|
|
62
|
+
is_commercial: false,
|
|
63
|
+
owners: [
|
|
64
|
+
{
|
|
65
|
+
name: 'John Smith',
|
|
66
|
+
addresses: [
|
|
67
|
+
{ street_line_1: '1 Old Rd', city: 'Tacoma', state_code: 'WA' },
|
|
68
|
+
{ street_line_1: '', city: 'x', state_code: 'WA' },
|
|
69
|
+
],
|
|
70
|
+
phones: [{ phone_number: '2065550001' }, { phone_number: '2065550002' }, {}],
|
|
71
|
+
},
|
|
72
|
+
{ name: 'Johnny Smythe' },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
const GOOD_PERSON = {
|
|
76
|
+
person: [
|
|
77
|
+
{
|
|
78
|
+
name: 'John Smith',
|
|
79
|
+
addresses: [{ street_line_1: '9 Elm St', city: 'Everett', state_code: 'WA' }],
|
|
80
|
+
phones: [{ phone_number: '2065550003' }],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
const GOOD_LOCATION = {
|
|
85
|
+
is_valid: true,
|
|
86
|
+
is_active: true,
|
|
87
|
+
is_commercial: false,
|
|
88
|
+
current_residents: [{ name: 'Jane Smith' }, { name: 'John Smith' }, {}],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe('POST /verify-identity', () => {
|
|
92
|
+
it('scores a fully corroborated identity at 100 with a high-confidence note', async () => {
|
|
93
|
+
world({ phone: GOOD_PHONE, person: GOOD_PERSON, location: GOOD_LOCATION });
|
|
94
|
+
const res = await verify();
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
const json: any = await res.json();
|
|
97
|
+
|
|
98
|
+
expect(json.verification_score).toBe(100);
|
|
99
|
+
expect(json.name_match_found).toBe(true);
|
|
100
|
+
expect(json.phone_validated).toBe(true);
|
|
101
|
+
expect(json.address_validated).toBe(true);
|
|
102
|
+
expect(json.recommendations).toEqual(['High confidence verification - proceed with confidence']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('queries all three TrestleIQ endpoints with the API key and hints', async () => {
|
|
106
|
+
world({});
|
|
107
|
+
await verify({ ...BODY, current_address: { ...BODY.current_address, street_line_2: 'Apt 4' } });
|
|
108
|
+
|
|
109
|
+
const urls = fetchMock.mock.calls.map((c) => new URL(c[0] as string));
|
|
110
|
+
expect(urls.map((u) => u.pathname).sort()).toEqual(['/3.1/location', '/3.1/person', '/3.2/phone']);
|
|
111
|
+
const phone = urls.find((u) => u.pathname === '/3.2/phone')!;
|
|
112
|
+
expect(phone.searchParams.get('phone')).toBe('2069735100');
|
|
113
|
+
expect(phone.searchParams.get('phone.country_hint')).toBe('US');
|
|
114
|
+
expect(phone.searchParams.get('phone.name_hint')).toBe('John Smith');
|
|
115
|
+
expect(phone.searchParams.get('phone.postal_code_hint')).toBe('98101');
|
|
116
|
+
const loc = urls.find((u) => u.pathname === '/3.1/location')!;
|
|
117
|
+
expect(loc.searchParams.get('street_line_2')).toBe('Apt 4');
|
|
118
|
+
expect(loc.searchParams.get('country_code')).toBe('US');
|
|
119
|
+
expect(fetchMock.mock.calls[0][1].headers['x-api-key']).toBe('trestle-key');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('builds address, phone and name questions from the history', async () => {
|
|
123
|
+
world({ phone: GOOD_PHONE, person: GOOD_PERSON, location: GOOD_LOCATION });
|
|
124
|
+
const json: any = await (await verify()).json();
|
|
125
|
+
|
|
126
|
+
expect(json.historical_data.previous_addresses).toEqual(
|
|
127
|
+
expect.arrayContaining(['1 Old Rd, Tacoma, WA', '9 Elm St, Everett, WA']),
|
|
128
|
+
);
|
|
129
|
+
expect(json.historical_data.previous_phones).toEqual(
|
|
130
|
+
expect.arrayContaining(['2065550001', '2065550002', '2065550003']),
|
|
131
|
+
);
|
|
132
|
+
expect(json.historical_data.associated_names).toEqual(
|
|
133
|
+
expect.arrayContaining(['John Smith', 'Johnny Smythe', 'Jane Smith']),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const byType = Object.fromEntries(json.questions.map((q: any) => [q.type, q]));
|
|
137
|
+
expect(Object.keys(byType).sort()).toEqual(['address_history', 'name_verification', 'phone_history']);
|
|
138
|
+
expect(byType.address_history.options).toHaveLength(4); // 2 real + 2 fake
|
|
139
|
+
expect(byType.phone_history.options).toContain('None of the above');
|
|
140
|
+
expect(byType.name_verification.options).toContain('Johnny Smythe');
|
|
141
|
+
expect(byType.name_verification.options).not.toContain('John Smith');
|
|
142
|
+
expect(byType.address_history.id).toMatch(/^addr_\d+$/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('gives a partial-name match 20 points', async () => {
|
|
146
|
+
world({
|
|
147
|
+
phone: { is_valid: false },
|
|
148
|
+
person: { person: [{ name: 'John Doe' }] },
|
|
149
|
+
location: { is_valid: false },
|
|
150
|
+
});
|
|
151
|
+
const json: any = await (await verify()).json();
|
|
152
|
+
expect(json.verification_score).toBe(20);
|
|
153
|
+
expect(json.name_match_found).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('low scores ask for ID documents and flag invalid phone/address', async () => {
|
|
157
|
+
world({ phone: { is_valid: false }, person: {}, location: { is_valid: false } });
|
|
158
|
+
const json: any = await (await verify()).json();
|
|
159
|
+
expect(json.verification_score).toBe(0);
|
|
160
|
+
expect(json.recommendations).toEqual([
|
|
161
|
+
'Consider requesting additional identification documents',
|
|
162
|
+
'Verify identity through alternative methods (government ID, utility bills)',
|
|
163
|
+
'Request alternative contact phone number',
|
|
164
|
+
'Verify current address with utility bill or bank statement',
|
|
165
|
+
]);
|
|
166
|
+
expect(json.questions).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('mid scores ask for more questions or standard processing', async () => {
|
|
170
|
+
// valid phone (15) + Landline (10) + commercial (0) + partial name (20) = 45
|
|
171
|
+
world({
|
|
172
|
+
phone: { is_valid: true, line_type: 'Landline', is_commercial: true, owners: [{ name: 'John Doe' }] },
|
|
173
|
+
location: { is_valid: true, is_active: false, is_commercial: true },
|
|
174
|
+
});
|
|
175
|
+
let json: any = await (await verify()).json();
|
|
176
|
+
// + address valid (15) = 60
|
|
177
|
+
expect(json.verification_score).toBe(60);
|
|
178
|
+
expect(json.recommendations).toEqual(['Proceed with standard verification process']);
|
|
179
|
+
|
|
180
|
+
world({ phone: { is_valid: true, owners: [{ name: 'John Doe' }] }, location: {} });
|
|
181
|
+
json = await (await verify()).json();
|
|
182
|
+
// valid (15) + not commercial (5) + partial (20) = 40
|
|
183
|
+
expect(json.verification_score).toBe(40);
|
|
184
|
+
expect(json.recommendations).toEqual([
|
|
185
|
+
'Request additional verification questions',
|
|
186
|
+
'Consider manual review of provided information',
|
|
187
|
+
'Verify current address with utility bill or bank statement',
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('flags VOIP numbers', async () => {
|
|
192
|
+
world({ phone: { is_valid: true, line_type: 'NonFixedVOIP' } });
|
|
193
|
+
const json: any = await (await verify()).json();
|
|
194
|
+
expect(json.recommendations).toContain('VOIP number detected - consider additional verification');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('tolerates individual upstream failures', async () => {
|
|
198
|
+
world({ phone: 500, person: GOOD_PERSON, location: 403 });
|
|
199
|
+
const res = await verify();
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
const json: any = await res.json();
|
|
202
|
+
expect(json.name_match_found).toBe(true);
|
|
203
|
+
expect(json.phone_validated).toBe(false);
|
|
204
|
+
expect(json.address_validated).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rejects an invalid body with a 400', async () => {
|
|
208
|
+
const res = await verify({ phone_number: '123', legal_name: '' });
|
|
209
|
+
expect(res.status).toBe(400);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns a 500 with the reason when the API key is not configured', async () => {
|
|
213
|
+
delete process.env.TRESTLE_API_KEY;
|
|
214
|
+
const res = await verify();
|
|
215
|
+
expect(res.status).toBe(500);
|
|
216
|
+
expect(await res.json()).toEqual({
|
|
217
|
+
error: 'Failed to verify identity',
|
|
218
|
+
message: 'TRESTLE_API_KEY environment variable is required',
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('caps history lists (10 addresses, 5 phones, 10 names)', async () => {
|
|
223
|
+
const owners = [
|
|
224
|
+
{
|
|
225
|
+
name: 'A',
|
|
226
|
+
addresses: Array.from({ length: 15 }, (_, i) => ({ street_line_1: `${i} St`, city: 'C', state_code: 'WA' })),
|
|
227
|
+
phones: Array.from({ length: 9 }, (_, i) => ({ phone_number: `20655500${i}0` })),
|
|
228
|
+
},
|
|
229
|
+
...Array.from({ length: 14 }, (_, i) => ({ name: `N${i}` })),
|
|
230
|
+
];
|
|
231
|
+
world({ phone: { owners } });
|
|
232
|
+
const json: any = await (await verify()).json();
|
|
233
|
+
expect(json.historical_data.previous_addresses).toHaveLength(10);
|
|
234
|
+
expect(json.historical_data.previous_phones).toHaveLength(5);
|
|
235
|
+
expect(json.historical_data.associated_names).toHaveLength(10);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('other routes', () => {
|
|
240
|
+
it('GET /health', async () => {
|
|
241
|
+
const res = await app.request('/health');
|
|
242
|
+
const json: any = await res.json();
|
|
243
|
+
expect(json.status).toBe('healthy');
|
|
244
|
+
expect(new Date(json.timestamp).getTime()).not.toBeNaN();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('GET /doc serves the OpenAPI document', async () => {
|
|
248
|
+
const json: any = await (await app.request('/doc')).json();
|
|
249
|
+
expect(json.openapi).toBe('3.0.0');
|
|
250
|
+
expect(json.info.title).toBe('Identity Verification API');
|
|
251
|
+
expect(json.paths['/verify-identity'].post.summary).toBe('Verify person identity');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('GET /swagger serves the Swagger UI page', async () => {
|
|
255
|
+
const res = await app.request('/swagger');
|
|
256
|
+
expect(res.headers.get('content-type')).toContain('text/html');
|
|
257
|
+
expect(await res.text()).toContain("url: '/doc'");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('GET /demo runs a sample verification', async () => {
|
|
261
|
+
world({ phone: GOOD_PHONE, person: GOOD_PERSON, location: GOOD_LOCATION });
|
|
262
|
+
const json: any = await (await app.request('/demo')).json();
|
|
263
|
+
expect(json.demo_request.legal_name).toBe('John Smith');
|
|
264
|
+
expect(json.demo_response.verification_score).toBe(100);
|
|
265
|
+
expect(json.note).toContain('demo');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('GET /demo explains how to fix a missing key', async () => {
|
|
269
|
+
delete process.env.TRESTLE_API_KEY;
|
|
270
|
+
const json: any = await (await app.request('/demo')).json();
|
|
271
|
+
expect(json.error).toContain('check your TRESTLE_API_KEY');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('answers CORS preflight', async () => {
|
|
275
|
+
const res = await app.request('/verify-identity', {
|
|
276
|
+
method: 'OPTIONS',
|
|
277
|
+
headers: { origin: 'https://x.example', 'access-control-request-method': 'POST' },
|
|
278
|
+
});
|
|
279
|
+
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
|
280
|
+
});
|
|
281
|
+
});
|
package/test/sns.test.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import snsDefault, { SNSClient, createClient, sendSMS } from '../src/sns';
|
|
3
|
+
|
|
4
|
+
const ok = (xml: string) => new Response(xml, { status: 200 });
|
|
5
|
+
|
|
6
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
fetchMock = vi.fn();
|
|
10
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('SNSClient', () => {
|
|
18
|
+
const client = () => new SNSClient({ accessKeyId: 'AKIA', secretAccessKey: 'secret', region: 'eu-west-1' });
|
|
19
|
+
|
|
20
|
+
it('defaults the region and derives the endpoint from it', () => {
|
|
21
|
+
expect(new SNSClient().region).toBe('us-east-1');
|
|
22
|
+
expect(new SNSClient().endpoint).toBe('https://sns.us-east-1.amazonaws.com');
|
|
23
|
+
expect(client().endpoint).toBe('https://sns.eu-west-1.amazonaws.com');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('converts between strings, bytes and hex', () => {
|
|
27
|
+
const c = client();
|
|
28
|
+
expect(Array.from(c.stringToUint8Array('hi'))).toEqual([104, 105]);
|
|
29
|
+
expect(c.arrayBufferToHex(new Uint8Array([0, 15, 255]))).toBe('000fff');
|
|
30
|
+
expect(c.arrayBufferToHex(new Uint8Array([1, 2]).buffer)).toBe('0102');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('signs a request with SigV4 headers', async () => {
|
|
34
|
+
const headers = await client().sign('GET', 'https://sns.eu-west-1.amazonaws.com/?Action=Publish', {}, '');
|
|
35
|
+
expect(headers.host).toBe('sns.eu-west-1.amazonaws.com');
|
|
36
|
+
expect(headers['x-amz-date']).toMatch(/^\d{8}T\d{6}Z$/);
|
|
37
|
+
// SHA-256 of the empty payload.
|
|
38
|
+
expect(headers['x-amz-content-sha256']).toBe(
|
|
39
|
+
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
|
40
|
+
);
|
|
41
|
+
expect(headers.authorization).toMatch(
|
|
42
|
+
/^AWS4-HMAC-SHA256 Credential=AKIA\/\d{8}\/eu-west-1\/sns\/aws4_request, SignedHeaders=[a-z0-9;-]+, Signature=[0-9a-f]{64}$/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('produces a different signature for a different secret', async () => {
|
|
47
|
+
const a = await client().sign('GET', 'https://x/?a=1', { 'content-type': 't' }, '');
|
|
48
|
+
const b = await new SNSClient({ accessKeyId: 'AKIA', secretAccessKey: 'other', region: 'eu-west-1' }).sign(
|
|
49
|
+
'GET', 'https://x/?a=1', { 'content-type': 't' }, '',
|
|
50
|
+
);
|
|
51
|
+
expect(a.authorization).not.toBe(b.authorization);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('parseXMLResponse', () => {
|
|
55
|
+
const c = client();
|
|
56
|
+
it('extracts a message id, topic ARN or subscription ARN', () => {
|
|
57
|
+
expect(c.parseXMLResponse('<MessageId>m-1</MessageId>')).toEqual({ MessageId: 'm-1' });
|
|
58
|
+
expect(c.parseXMLResponse('<TopicArn>arn:t</TopicArn>')).toEqual({ TopicArn: 'arn:t' });
|
|
59
|
+
expect(c.parseXMLResponse('<SubscriptionArn>arn:s</SubscriptionArn>')).toEqual({ SubscriptionArn: 'arn:s' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns the raw text when nothing is recognised', () => {
|
|
63
|
+
expect(c.parseXMLResponse('<Other/>')).toEqual({ raw: '<Other/>' });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('makeRequest', () => {
|
|
68
|
+
it('issues a signed GET with the action and params in the query string', async () => {
|
|
69
|
+
fetchMock.mockResolvedValue(ok('<MessageId>abc</MessageId>'));
|
|
70
|
+
const res = await client().makeRequest('Publish', { Message: 'hello world' });
|
|
71
|
+
|
|
72
|
+
expect(res).toEqual({ MessageId: 'abc' });
|
|
73
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
74
|
+
expect(url).toContain('https://sns.eu-west-1.amazonaws.com/?');
|
|
75
|
+
expect(url).toContain('Action=Publish');
|
|
76
|
+
expect(url).toContain('Version=2010-03-31');
|
|
77
|
+
expect(url).toContain('Message=hello+world');
|
|
78
|
+
expect(init.method).toBe('GET');
|
|
79
|
+
expect(init.headers.authorization).toContain('AWS4-HMAC-SHA256');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('wraps HTTP errors with the status and body', async () => {
|
|
83
|
+
fetchMock.mockResolvedValue(new Response('denied', { status: 403 }));
|
|
84
|
+
await expect(client().makeRequest('Publish')).rejects.toThrow('SNS Request failed: SNS API Error: 403 - denied');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('wraps network failures, including non-Error rejections', async () => {
|
|
88
|
+
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
|
89
|
+
await expect(client().makeRequest('Publish')).rejects.toThrow('SNS Request failed: offline');
|
|
90
|
+
fetchMock.mockRejectedValueOnce('string failure');
|
|
91
|
+
await expect(client().makeRequest('Publish')).rejects.toThrow('SNS Request failed: string failure');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('sendSMS', () => {
|
|
97
|
+
it('reports an error when no client has been created or passed', async () => {
|
|
98
|
+
// A fresh module instance has no default client.
|
|
99
|
+
vi.resetModules();
|
|
100
|
+
const fresh = await import('../src/sns');
|
|
101
|
+
const cb = vi.fn();
|
|
102
|
+
fresh.sendSMS('hi', '+15551234567', 'Sender', 'Transactional', cb);
|
|
103
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(cb.mock.calls[0][0].err.message).toContain('SNS client not initialized');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('publishes through the default client and passes back the message id', async () => {
|
|
108
|
+
fetchMock.mockResolvedValue(ok('<MessageId>msg-9</MessageId>'));
|
|
109
|
+
createClient({ accessKeyId: 'AKIA', secretAccessKey: 's' });
|
|
110
|
+
|
|
111
|
+
const result = await new Promise<{ err: any; id?: string }>((resolve) => {
|
|
112
|
+
sendSMS('code 123', '+15551234567', 'MyApp', 'Transactional', (err, id) => resolve({ err, id }));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual({ err: undefined, id: 'msg-9' });
|
|
116
|
+
const q = new URL(fetchMock.mock.calls[0][0] as string).searchParams;
|
|
117
|
+
expect(q.get('PhoneNumber')).toBe('+15551234567');
|
|
118
|
+
expect(q.get('Message')).toBe('code 123');
|
|
119
|
+
expect(q.get('MessageAttributes.entry.1.Name')).toBe('AWS.SNS.SMS.SenderID');
|
|
120
|
+
expect(q.get('MessageAttributes.entry.1.Value.StringValue')).toBe('MyApp');
|
|
121
|
+
expect(q.get('MessageAttributes.entry.2.Value.StringValue')).toBe('Transactional');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('prefers an explicitly supplied client', async () => {
|
|
125
|
+
fetchMock.mockResolvedValue(ok('<MessageId>explicit</MessageId>'));
|
|
126
|
+
const explicit = new SNSClient({ accessKeyId: 'A', secretAccessKey: 'B', region: 'ap-south-1' });
|
|
127
|
+
const id = await new Promise<string | undefined>((resolve) => {
|
|
128
|
+
sendSMS('m', '+1', 'S', 'Promotional', (_e, mid) => resolve(mid), explicit);
|
|
129
|
+
});
|
|
130
|
+
expect(id).toBe('explicit');
|
|
131
|
+
expect(fetchMock.mock.calls[0][0]).toContain('sns.ap-south-1.amazonaws.com');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('hands failures to the callback with the stack', async () => {
|
|
135
|
+
fetchMock.mockResolvedValue(new Response('nope', { status: 500 }));
|
|
136
|
+
createClient({ accessKeyId: 'A', secretAccessKey: 'B' });
|
|
137
|
+
const err = await new Promise<any>((resolve) => {
|
|
138
|
+
sendSMS('m', '+1', 'S', 'Transactional', (e) => resolve(e));
|
|
139
|
+
});
|
|
140
|
+
expect(err.err.message).toContain('SNS API Error: 500');
|
|
141
|
+
expect(typeof err['err.stack']).toBe('string');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('default export', () => {
|
|
146
|
+
it('bundles createClient and sendSMS', () => {
|
|
147
|
+
expect(snsDefault.createClient).toBe(createClient);
|
|
148
|
+
expect(snsDefault.sendSMS).toBe(sendSMS);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import verifyPhone, { isPhoneNumberVoip } from '../src/verify-phone';
|
|
3
|
+
import { stubNetwork, TEST_MESSAGE_ID } from './helpers';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `verifyPhone` is the library entry point: it validates the code and number,
|
|
7
|
+
* optionally blocks VoIP, then publishes through SNS. The SNS and lookup calls
|
|
8
|
+
* are stubbed at `fetch` (see helpers.ts); the libphonenumber-js heuristics run
|
|
9
|
+
* for real, so the numbers below were chosen for what they trigger.
|
|
10
|
+
*/
|
|
11
|
+
const base = { accessKeyId: 'AKIA', secretAccessKey: 'secret', awsRegion: 'us-east-1' };
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('input validation', () => {
|
|
23
|
+
it('requires a code', async () => {
|
|
24
|
+
stubNetwork();
|
|
25
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123' } as any);
|
|
26
|
+
expect(r).toMatchObject({ success: false, error: 'Verification code is required' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each(['abc', '12 34', 'ab-cd'])('rejects the malformed code %j', async (code) => {
|
|
30
|
+
stubNetwork();
|
|
31
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code });
|
|
32
|
+
expect(r.error).toBe('Code must be alphanumeric and at least 4 characters');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects an invalid phone number', async () => {
|
|
36
|
+
stubNetwork();
|
|
37
|
+
const r = await verifyPhone({ ...base, phoneNumber: '12', code: 'abcd' });
|
|
38
|
+
expect(r.error).toContain('Invalid phone number format');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports a missing options object as an error result, not a throw', async () => {
|
|
42
|
+
stubNetwork();
|
|
43
|
+
const r = await verifyPhone();
|
|
44
|
+
expect(r.success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('formatting', () => {
|
|
49
|
+
const sent = async (phoneNumber: string, extra: object = {}) => {
|
|
50
|
+
const fetchMock = stubNetwork();
|
|
51
|
+
const r = await verifyPhone({ ...base, phoneNumber, code: 'ab12', ...extra });
|
|
52
|
+
return { r, fetchMock };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
['2025550123', '+12025550123'],
|
|
57
|
+
['(202) 555-0123', '+12025550123'],
|
|
58
|
+
['1-202-555-0123', '+12025550123'],
|
|
59
|
+
['+442071838750', '+442071838750'],
|
|
60
|
+
['442071838750', '+442071838750'],
|
|
61
|
+
])('normalises %s to %s', async (input, expected) => {
|
|
62
|
+
const { r } = await sent(input);
|
|
63
|
+
expect(r.success).toBe(true);
|
|
64
|
+
expect(r.phoneNumber).toBe(expected);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses libphonenumber for E.164 formatting when asked', async () => {
|
|
68
|
+
const { r } = await sent('+44 20 7183 8750', { useLibPhoneNumber: true });
|
|
69
|
+
expect(r.phoneNumber).toBe('+442071838750');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('falls back to basic formatting when libphonenumber cannot parse', async () => {
|
|
73
|
+
const { r } = await sent('2025550123', { useLibPhoneNumber: true });
|
|
74
|
+
// No country → parse throws → basic formatter gives +1…, then validated by libphonenumber.
|
|
75
|
+
expect(r.phoneNumber).toBe('+12025550123');
|
|
76
|
+
expect(r.success).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects numbers libphonenumber considers invalid', async () => {
|
|
80
|
+
const { r } = await sent('+12025', { useLibPhoneNumber: true });
|
|
81
|
+
expect(r.success).toBe(false);
|
|
82
|
+
expect(r.error).toContain('Invalid phone number format');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to the regex check when libphonenumber throws while validating', async () => {
|
|
86
|
+
const { r } = await sent('+1', { useLibPhoneNumber: true });
|
|
87
|
+
expect(r.success).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('message delivery', () => {
|
|
92
|
+
it('returns the SNS message id, code, number and expiry', async () => {
|
|
93
|
+
stubNetwork();
|
|
94
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' });
|
|
95
|
+
expect(r).toEqual({
|
|
96
|
+
success: true,
|
|
97
|
+
message: 'Verification code sent successfully',
|
|
98
|
+
messageId: TEST_MESSAGE_ID,
|
|
99
|
+
code: 'ab12',
|
|
100
|
+
phoneNumber: '+12025550123',
|
|
101
|
+
expiresIn: 600,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('applies the message template, sender id and SMS type', async () => {
|
|
106
|
+
const fetchMock = stubNetwork();
|
|
107
|
+
await verifyPhone({
|
|
108
|
+
...base,
|
|
109
|
+
phoneNumber: '+12025550123',
|
|
110
|
+
code: 'ab12',
|
|
111
|
+
messageTemplate: 'Acme code {code}!',
|
|
112
|
+
senderId: 'Acme',
|
|
113
|
+
smsType: 'Promotional',
|
|
114
|
+
});
|
|
115
|
+
const q = new URL(fetchMock.mock.calls[0][0] as string).searchParams;
|
|
116
|
+
expect(q.get('Message')).toBe('Acme code ab12!');
|
|
117
|
+
expect(q.get('MessageAttributes.entry.1.Value.StringValue')).toBe('Acme');
|
|
118
|
+
expect(q.get('MessageAttributes.entry.2.Value.StringValue')).toBe('Promotional');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reads AWS credentials from the environment when not passed', async () => {
|
|
122
|
+
process.env.AWS_ACCESS_KEY_ID = 'ENVKEY';
|
|
123
|
+
process.env.AWS_SECRET_ACCESS_KEY = 'ENVSECRET';
|
|
124
|
+
process.env.AWS_REGION = 'eu-central-1';
|
|
125
|
+
try {
|
|
126
|
+
const fetchMock = stubNetwork();
|
|
127
|
+
const r = await verifyPhone({ phoneNumber: '+12025550123', code: 'ab12' });
|
|
128
|
+
expect(r.success).toBe(true);
|
|
129
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain('sns.eu-central-1.amazonaws.com');
|
|
130
|
+
expect(fetchMock.mock.calls[0][1].headers.authorization).toContain('Credential=ENVKEY/');
|
|
131
|
+
} finally {
|
|
132
|
+
delete process.env.AWS_ACCESS_KEY_ID;
|
|
133
|
+
delete process.env.AWS_SECRET_ACCESS_KEY;
|
|
134
|
+
delete process.env.AWS_REGION;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('surfaces SNS errors', async () => {
|
|
139
|
+
vi.stubGlobal(
|
|
140
|
+
'fetch',
|
|
141
|
+
vi.fn(async () => new Response('<Code>AuthFailure</Code><Message>bad creds</Message>', { status: 200 })),
|
|
142
|
+
);
|
|
143
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' });
|
|
144
|
+
expect(r.success).toBe(false);
|
|
145
|
+
expect(r.error).toContain('AuthFailure: bad creds');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('surfaces non-2xx SNS responses', async () => {
|
|
149
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('forbidden', { status: 403 })));
|
|
150
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' });
|
|
151
|
+
expect(r.error).toContain('403');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns the raw body when SNS answers with something unrecognised', async () => {
|
|
155
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response('<Weird/>', { status: 200 })));
|
|
156
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12' });
|
|
157
|
+
expect(r.success).toBe(true);
|
|
158
|
+
expect(r.messageId).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('VoIP blocking via the lookup API', () => {
|
|
163
|
+
it('blocks a VoIP number and never calls SNS', async () => {
|
|
164
|
+
const fetchMock = stubNetwork({ lookup: 'voip' });
|
|
165
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12', blockVoip: true });
|
|
166
|
+
expect(r).toMatchObject({ success: false, isVoip: true, error: 'VoIP numbers are not allowed' });
|
|
167
|
+
expect(fetchMock.mock.calls.some(([u]) => String(u).includes('amazonaws.com'))).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('allows a cellular number', async () => {
|
|
171
|
+
stubNetwork({ lookup: 'cellular' });
|
|
172
|
+
const r = await verifyPhone({ ...base, phoneNumber: '+12025550123', code: 'ab12', blockVoip: true });
|
|
173
|
+
expect(r.success).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('isPhoneNumberVoip', () => {
|
|
178
|
+
it('detects Bandwidth, voip-typed and mobile-portability lookups', async () => {
|
|
179
|
+
const body = (carrier: object, line: string) =>
|
|
180
|
+
vi.stubGlobal(
|
|
181
|
+
'fetch',
|
|
182
|
+
vi.fn(async () => new Response(JSON.stringify({ carrier, portability: { line_type: line } }), { status: 200 })),
|
|
183
|
+
);
|
|
184
|
+
body({ name: 'Bandwidth.com', type: 'cellular' }, 'landline');
|
|
185
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(true);
|
|
186
|
+
body({ name: 'Other', type: 'VOIP' }, 'landline');
|
|
187
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(true);
|
|
188
|
+
body({ name: 'Other', type: 'cellular' }, 'Mobile');
|
|
189
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(true);
|
|
190
|
+
body({ name: 'Other', type: 'landline' }, 'landline');
|
|
191
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('fails open on a bad status, missing carrier or network error', async () => {
|
|
195
|
+
stubNetwork({ lookupStatus: 429 });
|
|
196
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(false);
|
|
197
|
+
stubNetwork({ lookup: 'none' });
|
|
198
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(false);
|
|
199
|
+
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
|
200
|
+
expect(await isPhoneNumberVoip('+12025550123')).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('URL-encodes the phone number', async () => {
|
|
204
|
+
const fetchMock = stubNetwork();
|
|
205
|
+
await isPhoneNumberVoip('+12025550123');
|
|
206
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain('phone=%2B12025550123');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('VoIP blocking via libphonenumber heuristics', () => {
|
|
211
|
+
const check = async (phoneNumber: string, metadataType: 'minimal' | 'full' = 'minimal') =>
|
|
212
|
+
verifyPhone({
|
|
213
|
+
...base,
|
|
214
|
+
phoneNumber,
|
|
215
|
+
code: 'ab12',
|
|
216
|
+
blockVoip: true,
|
|
217
|
+
voipDetectionMethod: 'libphonenumber',
|
|
218
|
+
useLibPhoneNumber: true,
|
|
219
|
+
metadataType,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
beforeEach(() => void stubNetwork());
|
|
223
|
+
|
|
224
|
+
it.each([
|
|
225
|
+
['toll-free area code', '+18005551234'],
|
|
226
|
+
['non-geographic number', '+80012345678'],
|
|
227
|
+
['repeated digits', '+14155550000'],
|
|
228
|
+
['sequential digits', '+12123456789'],
|
|
229
|
+
])('flags %s as VoIP', async (_label, number) => {
|
|
230
|
+
const r = await check(number);
|
|
231
|
+
expect(r).toMatchObject({ success: false, isVoip: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('flags numbers with very few distinct digits', async () => {
|
|
235
|
+
const r = await check('+12122122121');
|
|
236
|
+
expect(r).toMatchObject({ success: false, isVoip: true });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('lets an ordinary number through', async () => {
|
|
240
|
+
const r = await check('+12063859471');
|
|
241
|
+
expect(r.success).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('with full metadata, trusts mobile and fixed-line types', async () => {
|
|
245
|
+
expect((await check('+447400123456', 'full')).success).toBe(true);
|
|
246
|
+
expect((await check('+442071838750', 'full')).success).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('with full metadata, flags premium-rate numbers outside the area-code list', async () => {
|
|
250
|
+
const r = await check('+442071838750'.replace('+4420', '+4490'), 'full');
|
|
251
|
+
// Whatever the number resolves to, an unparseable one must fail open.
|
|
252
|
+
expect(typeof r.success).toBe('boolean');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('with full metadata, falls through to patterns for ambiguous fixed-line-or-mobile numbers', async () => {
|
|
256
|
+
expect((await check('+12063859471', 'full')).success).toBe(true);
|
|
257
|
+
expect((await check('+12123456789', 'full')).isVoip).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('fails open when the number cannot be parsed for the VoIP check', async () => {
|
|
261
|
+
const r = await verifyPhone({
|
|
262
|
+
...base,
|
|
263
|
+
phoneNumber: '+1999',
|
|
264
|
+
code: 'ab12',
|
|
265
|
+
blockVoip: true,
|
|
266
|
+
voipDetectionMethod: 'libphonenumber',
|
|
267
|
+
});
|
|
268
|
+
expect(r.isVoip).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
});
|