verify-phone-sms 0.9.76 → 0.9.77
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/README.md +3 -1
- package/package.json +4 -3
- package/src/verify-phone-server.ts +8 -5
- package/test/api.test.ts +122 -100
- package/test/helpers.ts +80 -0
- package/test/integration.test.ts +94 -95
- package/test/server.test.ts +115 -143
- package/test/voip.test.ts +74 -81
package/README.md
CHANGED
|
@@ -252,7 +252,9 @@ X-API-Key: your_api_key
|
|
|
252
252
|
|
|
253
253
|
## API Documentation
|
|
254
254
|
|
|
255
|
-
Visit `/docs`
|
|
255
|
+
Visit `/docs` for the interactive Swagger UI, or `/openapi.json` for the
|
|
256
|
+
OpenAPI 3.0 spec it renders. Both, along with `/` and `/health`, are
|
|
257
|
+
unauthenticated.
|
|
256
258
|
|
|
257
259
|
## Authentication
|
|
258
260
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verify-phone-sms",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.77",
|
|
4
4
|
"description": "SMS Phone Verification API using AWS SNS HTTP API with Hono server on Cloudflare Workers",
|
|
5
5
|
"main": "src/verify-phone.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"test": "vitest run",
|
|
32
32
|
"test:run": "vitest run",
|
|
33
33
|
"test:ui": "vitest --ui",
|
|
34
|
-
"test:ci": "vitest run --reporter=default --reporter=junit --outputFile=junit.xml"
|
|
34
|
+
"test:ci": "vitest run --reporter=default --reporter=junit --outputFile=junit.xml --coverage --coverage.reporter=lcov --coverage.reporter=text",
|
|
35
|
+
"coverage": "vitest run --coverage"
|
|
35
36
|
},
|
|
36
37
|
"keywords": [
|
|
37
38
|
"sms",
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"license": "MIT",
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@hono/swagger-ui": "^0.5.2",
|
|
52
|
-
"@hono/zod-openapi": "^
|
|
53
|
+
"@hono/zod-openapi": "^1.6.3",
|
|
53
54
|
"grab-api.js": "^0.9.131",
|
|
54
55
|
"hono": "^4.0.0",
|
|
55
56
|
"hono-rate-limiter": "^0.2.2",
|
|
@@ -95,6 +95,7 @@ app.get("/", (c) => {
|
|
|
95
95
|
send: "/api/send",
|
|
96
96
|
verify: "/api/verify",
|
|
97
97
|
docs: "/docs",
|
|
98
|
+
openapi: "/openapi.json",
|
|
98
99
|
},
|
|
99
100
|
});
|
|
100
101
|
});
|
|
@@ -109,6 +110,11 @@ app.get("/health", (c) => {
|
|
|
109
110
|
});
|
|
110
111
|
});
|
|
111
112
|
|
|
113
|
+
// Apply authentication to all API routes. This must be registered before the
|
|
114
|
+
// `/api/*` handlers below: Hono runs middleware only for routes defined after
|
|
115
|
+
// it, so registering this at the bottom of the file left every endpoint open.
|
|
116
|
+
app.use("/api/*", authenticateApiKey);
|
|
117
|
+
|
|
112
118
|
// Generate verification code
|
|
113
119
|
function generateCode(length = 6) {
|
|
114
120
|
const chars = "0123456789";
|
|
@@ -446,7 +452,7 @@ app.openAPIRegistry.registerComponent("securitySchemes", "apiKey", {
|
|
|
446
452
|
in: "header",
|
|
447
453
|
});
|
|
448
454
|
|
|
449
|
-
app.doc("/
|
|
455
|
+
app.doc("/openapi.json", {
|
|
450
456
|
openapi: "3.0.0",
|
|
451
457
|
info: {
|
|
452
458
|
title: "SMS Verification API",
|
|
@@ -466,10 +472,7 @@ app.doc("/docs", {
|
|
|
466
472
|
});
|
|
467
473
|
|
|
468
474
|
// Swagger UI
|
|
469
|
-
app.get("/docs", swaggerUI({ url: "/
|
|
470
|
-
|
|
471
|
-
// Apply authentication to all API routes
|
|
472
|
-
app.use("/api/*", authenticateApiKey);
|
|
475
|
+
app.get("/docs", swaggerUI({ url: "/openapi.json" }));
|
|
473
476
|
|
|
474
477
|
// Error handling
|
|
475
478
|
app.onError((err, c) => {
|
package/test/api.test.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-level tests for the routes `src/verify-phone-server.ts` serves:
|
|
3
|
+
* `/`, `/health`, `POST /api/send`, `POST /api/verify`, `POST /api/sms`, and
|
|
4
|
+
* the documentation pair `/docs` (Swagger UI) and `/openapi.json` (the spec).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
8
|
+
import { vi } from 'vitest';
|
|
2
9
|
import { createApp } from '../src/verify-phone-server';
|
|
10
|
+
import { apiRequest, stubNetwork, TEST_API_KEY, TEST_MESSAGE_ID } from './helpers';
|
|
3
11
|
|
|
4
12
|
declare global {
|
|
5
13
|
// eslint-disable-next-line no-var
|
|
@@ -11,195 +19,209 @@ describe('SMS Verification API', () => {
|
|
|
11
19
|
|
|
12
20
|
beforeEach(() => {
|
|
13
21
|
app = createApp(globalThis.env);
|
|
22
|
+
stubNetwork();
|
|
14
23
|
});
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('Service endpoints', () => {
|
|
30
|
+
it('should describe the API on the root endpoint', async () => {
|
|
31
|
+
const res = await app.request('http://localhost/', {}, globalThis.env);
|
|
32
|
+
const data: any = await res.json();
|
|
33
|
+
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
expect(data).toMatchObject({
|
|
36
|
+
success: true,
|
|
37
|
+
message: 'SMS Verification API',
|
|
38
|
+
});
|
|
39
|
+
expect(data.endpoints).toMatchObject({
|
|
40
|
+
health: '/health',
|
|
41
|
+
send: '/api/send',
|
|
42
|
+
verify: '/api/verify',
|
|
43
|
+
docs: '/docs',
|
|
44
|
+
openapi: '/openapi.json',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
17
48
|
it('should return health status', async () => {
|
|
18
|
-
const
|
|
19
|
-
const res = await app.request(req, globalThis.env);
|
|
49
|
+
const res = await app.request('http://localhost/health', {}, globalThis.env);
|
|
20
50
|
const data: any = await res.json();
|
|
21
51
|
|
|
22
52
|
expect(res.status).toBe(200);
|
|
23
53
|
expect(data).toMatchObject({
|
|
24
|
-
|
|
54
|
+
success: true,
|
|
55
|
+
status: 'healthy',
|
|
25
56
|
});
|
|
26
57
|
expect(data.timestamp).toBeDefined();
|
|
27
58
|
});
|
|
59
|
+
|
|
60
|
+
it('should not require an API key for the public endpoints', async () => {
|
|
61
|
+
for (const path of ['/', '/health', '/docs', '/openapi.json']) {
|
|
62
|
+
const res = await app.request(`http://localhost${path}`, {}, globalThis.env);
|
|
63
|
+
expect(res.status, `${path} should be public`).toBe(200);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
28
66
|
});
|
|
29
67
|
|
|
30
68
|
describe('Send Verification Code', () => {
|
|
31
69
|
it('should require API key authentication', async () => {
|
|
32
|
-
const req =
|
|
33
|
-
const res = await app.request(req, globalThis.env);
|
|
70
|
+
const req = apiRequest('/api/send', { phoneNumber: '+12025550123' }, {});
|
|
71
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
34
72
|
const data: any = await res.json();
|
|
35
73
|
|
|
36
74
|
expect(res.status).toBe(401);
|
|
37
75
|
expect(data).toMatchObject({
|
|
38
|
-
|
|
76
|
+
success: false,
|
|
77
|
+
error: 'Unauthorized',
|
|
78
|
+
message: 'Invalid or missing API key',
|
|
39
79
|
});
|
|
40
80
|
});
|
|
41
81
|
|
|
42
82
|
it('should reject invalid API key', async () => {
|
|
43
|
-
const req =
|
|
44
|
-
|
|
45
|
-
'X-API-Key': 'invalid-key'
|
|
46
|
-
}
|
|
83
|
+
const req = apiRequest('/api/send', { phoneNumber: '+12025550123' }, {
|
|
84
|
+
'X-API-Key': 'wrong-key',
|
|
47
85
|
});
|
|
48
|
-
const res = await app.request(req, globalThis.env);
|
|
86
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
49
87
|
const data: any = await res.json();
|
|
50
88
|
|
|
51
89
|
expect(res.status).toBe(401);
|
|
52
|
-
expect(data).toMatchObject({
|
|
53
|
-
error: 'Invalid API key'
|
|
54
|
-
});
|
|
90
|
+
expect(data).toMatchObject({ success: false, error: 'Unauthorized' });
|
|
55
91
|
});
|
|
56
92
|
|
|
57
93
|
it('should accept Bearer token authentication', async () => {
|
|
58
|
-
const req =
|
|
59
|
-
|
|
60
|
-
'Authorization': 'Bearer test-api-key'
|
|
61
|
-
}
|
|
94
|
+
const req = apiRequest('/api/send', { phoneNumber: '+12025550123' }, {
|
|
95
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
62
96
|
});
|
|
63
|
-
const res = await app.request(req, globalThis.env);
|
|
97
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
64
98
|
const data: any = await res.json();
|
|
65
99
|
|
|
66
100
|
expect(res.status).toBe(200);
|
|
67
101
|
expect(data.success).toBe(true);
|
|
102
|
+
expect(data.messageId).toBe(TEST_MESSAGE_ID);
|
|
68
103
|
});
|
|
69
104
|
|
|
70
105
|
it('should reject invalid phone number format', async () => {
|
|
71
|
-
const req =
|
|
72
|
-
|
|
73
|
-
'X-API-Key': 'test-api-key'
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
const res = await app.request(req, globalThis.env);
|
|
106
|
+
const req = apiRequest('/api/send', { phoneNumber: 'invalid-phone' });
|
|
107
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
77
108
|
const data: any = await res.json();
|
|
78
109
|
|
|
79
110
|
expect(res.status).toBe(400);
|
|
80
|
-
expect(data).
|
|
81
|
-
|
|
82
|
-
});
|
|
111
|
+
expect(data.success).toBe(false);
|
|
112
|
+
expect(data.error).toContain('Invalid phone number format');
|
|
83
113
|
});
|
|
84
114
|
|
|
85
|
-
it('should format phone number
|
|
86
|
-
const req =
|
|
87
|
-
|
|
88
|
-
'X-API-Key': 'test-api-key'
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
const res = await app.request(req, globalThis.env);
|
|
115
|
+
it('should format a 10-digit phone number to E.164', async () => {
|
|
116
|
+
const req = apiRequest('/api/send', { phoneNumber: '2025550123' });
|
|
117
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
92
118
|
const data: any = await res.json();
|
|
93
119
|
|
|
94
120
|
expect(res.status).toBe(200);
|
|
95
121
|
expect(data.success).toBe(true);
|
|
122
|
+
expect(data.phoneNumber).toBe('+12025550123');
|
|
96
123
|
});
|
|
97
124
|
|
|
98
|
-
it('should
|
|
99
|
-
const req =
|
|
100
|
-
|
|
101
|
-
'X-API-Key': 'test-api-key'
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
const res = await app.request(req, globalThis.env);
|
|
125
|
+
it('should return the generated code and its expiry', async () => {
|
|
126
|
+
const req = apiRequest('/api/send', { phoneNumber: '+12025550123' });
|
|
127
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
105
128
|
const data: any = await res.json();
|
|
106
129
|
|
|
107
130
|
expect(res.status).toBe(200);
|
|
108
|
-
expect(data.
|
|
131
|
+
expect(data.code).toMatch(/^\d{6}$/);
|
|
132
|
+
expect(data.expiresIn).toBe(600);
|
|
109
133
|
});
|
|
110
134
|
|
|
111
|
-
it('should
|
|
112
|
-
const req =
|
|
113
|
-
|
|
114
|
-
'X-API-Key': 'test-api-key'
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
const res = await app.request(req, globalThis.env);
|
|
135
|
+
it('should send the caller-supplied code', async () => {
|
|
136
|
+
const req = apiRequest('/api/send', { phoneNumber: '+12025550123', code: '424242' });
|
|
137
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
118
138
|
const data: any = await res.json();
|
|
119
139
|
|
|
120
140
|
expect(res.status).toBe(200);
|
|
121
|
-
expect(data.
|
|
141
|
+
expect(data.code).toBe('424242');
|
|
122
142
|
});
|
|
123
143
|
});
|
|
124
144
|
|
|
125
145
|
describe('Verify Code', () => {
|
|
126
146
|
it('should require API key authentication', async () => {
|
|
127
|
-
const req =
|
|
128
|
-
const res = await app.request(req, globalThis.env);
|
|
147
|
+
const req = apiRequest('/api/verify', { phoneNumber: '+12025550123', code: '123456' }, {});
|
|
148
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
129
149
|
const data: any = await res.json();
|
|
130
150
|
|
|
131
151
|
expect(res.status).toBe(401);
|
|
132
|
-
expect(data).toMatchObject({
|
|
133
|
-
error: 'API key required'
|
|
134
|
-
});
|
|
152
|
+
expect(data).toMatchObject({ success: false, error: 'Unauthorized' });
|
|
135
153
|
});
|
|
136
154
|
|
|
137
155
|
it('should reject invalid API key', async () => {
|
|
138
|
-
const req =
|
|
139
|
-
|
|
140
|
-
'X-API-Key': 'invalid-key'
|
|
141
|
-
}
|
|
156
|
+
const req = apiRequest('/api/verify', { phoneNumber: '+12025550123', code: '123456' }, {
|
|
157
|
+
'X-API-Key': 'wrong-key',
|
|
142
158
|
});
|
|
143
|
-
const res = await app.request(req, globalThis.env);
|
|
144
|
-
const data: any = await res.json();
|
|
159
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
145
160
|
|
|
146
161
|
expect(res.status).toBe(401);
|
|
147
|
-
expect(data).toMatchObject({
|
|
148
|
-
error: 'Invalid API key'
|
|
149
|
-
});
|
|
150
162
|
});
|
|
151
163
|
|
|
152
|
-
it('should
|
|
153
|
-
const req =
|
|
154
|
-
|
|
155
|
-
'X-API-Key': 'test-api-key'
|
|
156
|
-
}
|
|
164
|
+
it('should accept Bearer token authentication', async () => {
|
|
165
|
+
const req = apiRequest('/api/verify', { phoneNumber: '+12025550123', code: '123456' }, {
|
|
166
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
157
167
|
});
|
|
158
|
-
const res = await app.request(req, globalThis.env);
|
|
168
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
159
169
|
const data: any = await res.json();
|
|
160
170
|
|
|
161
|
-
expect(res.status).toBe(
|
|
162
|
-
expect(data).toMatchObject({
|
|
163
|
-
error: 'Invalid phone number format'
|
|
164
|
-
});
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
expect(data).toMatchObject({ success: true, verified: true });
|
|
165
173
|
});
|
|
174
|
+
});
|
|
166
175
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
describe('General SMS', () => {
|
|
177
|
+
it('should require API key authentication', async () => {
|
|
178
|
+
const req = apiRequest('/api/sms', { phoneNumber: '+12025550123', message: 'hello' }, {});
|
|
179
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(401);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should send a custom message', async () => {
|
|
185
|
+
const req = apiRequest('/api/sms', { phoneNumber: '+12025550123', message: 'hello' });
|
|
186
|
+
const res = await app.request(req, undefined, globalThis.env);
|
|
174
187
|
const data: any = await res.json();
|
|
175
188
|
|
|
176
|
-
expect(res.status).toBe(
|
|
177
|
-
expect(data
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
expect(data).toMatchObject({
|
|
191
|
+
success: true,
|
|
192
|
+
message: 'SMS sent successfully',
|
|
193
|
+
messageId: TEST_MESSAGE_ID,
|
|
194
|
+
});
|
|
178
195
|
});
|
|
179
196
|
});
|
|
180
197
|
|
|
181
198
|
describe('Documentation Endpoints', () => {
|
|
182
|
-
it('should serve OpenAPI
|
|
183
|
-
const
|
|
184
|
-
const res = await app.request(req, globalThis.env);
|
|
199
|
+
it('should serve the OpenAPI spec as JSON', async () => {
|
|
200
|
+
const res = await app.request('http://localhost/openapi.json', {}, globalThis.env);
|
|
185
201
|
const data: any = await res.json();
|
|
186
202
|
|
|
187
203
|
expect(res.status).toBe(200);
|
|
188
|
-
expect(data).toMatchObject({
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
title: 'SMS API',
|
|
192
|
-
version: '1.0.0'
|
|
193
|
-
}
|
|
194
|
-
});
|
|
204
|
+
expect(data).toMatchObject({ openapi: '3.0.0' });
|
|
205
|
+
expect(data.info.title).toBe('SMS Verification API');
|
|
206
|
+
expect(data.paths['/api/send']).toBeDefined();
|
|
195
207
|
});
|
|
196
208
|
|
|
197
|
-
it('should serve Swagger UI', async () => {
|
|
198
|
-
const
|
|
199
|
-
const res = await app.request(req, globalThis.env);
|
|
209
|
+
it('should serve Swagger UI as HTML', async () => {
|
|
210
|
+
const res = await app.request('http://localhost/docs', {}, globalThis.env);
|
|
200
211
|
|
|
201
212
|
expect(res.status).toBe(200);
|
|
202
213
|
expect(res.headers.get('content-type')).toContain('text/html');
|
|
214
|
+
expect(await res.text()).toContain('/openapi.json');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('Error Handling', () => {
|
|
219
|
+
it('should return a JSON 404 for unknown endpoints', async () => {
|
|
220
|
+
const res = await app.request('http://localhost/nonexistent', {}, globalThis.env);
|
|
221
|
+
const data: any = await res.json();
|
|
222
|
+
|
|
223
|
+
expect(res.status).toBe(404);
|
|
224
|
+
expect(data).toMatchObject({ success: false, error: 'Not found' });
|
|
203
225
|
});
|
|
204
226
|
});
|
|
205
|
-
});
|
|
227
|
+
});
|
package/test/helpers.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test helpers.
|
|
3
|
+
*
|
|
4
|
+
* Both outbound calls this package makes — the AWS SNS `Publish` request and
|
|
5
|
+
* the sent.dm phone lookup — go through `fetch`. Stubbing `fetch` keeps the
|
|
6
|
+
* suite offline and deterministic; without it the VoIP tests hit a public API
|
|
7
|
+
* that rate-limits (HTTP 429) and the send tests hit SNS with fake credentials.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
export const TEST_API_KEY = 'test-api-key';
|
|
13
|
+
export const TEST_MESSAGE_ID = 'test-message-id-0001';
|
|
14
|
+
|
|
15
|
+
/** The subset of an SNS `Publish` response that `parseXMLResponse` reads. */
|
|
16
|
+
const snsPublishOk = `<?xml version="1.0"?>
|
|
17
|
+
<PublishResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
|
18
|
+
<PublishResult><MessageId>${TEST_MESSAGE_ID}</MessageId></PublishResult>
|
|
19
|
+
</PublishResponse>`;
|
|
20
|
+
|
|
21
|
+
export interface NetworkStubOptions {
|
|
22
|
+
/** What the phone lookup should report the number as. */
|
|
23
|
+
lookup?: 'voip' | 'cellular' | 'landline' | 'none';
|
|
24
|
+
/** Make the phone lookup fail with this status instead of answering. */
|
|
25
|
+
lookupStatus?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replace `globalThis.fetch` for the duration of a test. Any URL the package
|
|
30
|
+
* is not expected to call fails the test rather than escaping to the network.
|
|
31
|
+
*/
|
|
32
|
+
export function stubNetwork(options: NetworkStubOptions = {}) {
|
|
33
|
+
const { lookup = 'cellular', lookupStatus } = options;
|
|
34
|
+
|
|
35
|
+
const lookupBodies = {
|
|
36
|
+
voip: { carrier: { name: 'Bandwidth', type: 'voip' }, portability: { line_type: 'landline' } },
|
|
37
|
+
cellular: { carrier: { name: 'Verizon Wireless', type: 'cellular' }, portability: { line_type: 'landline' } },
|
|
38
|
+
landline: { carrier: { name: 'AT&T', type: 'landline' }, portability: { line_type: 'landline' } },
|
|
39
|
+
none: null,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
43
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
44
|
+
|
|
45
|
+
if (url.includes('amazonaws.com')) {
|
|
46
|
+
return new Response(snsPublishOk, {
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: { 'Content-Type': 'text/xml' },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (url.includes('sent.dm/api/phone-lookup')) {
|
|
53
|
+
if (lookupStatus) {
|
|
54
|
+
return new Response('rate limited', { status: lookupStatus });
|
|
55
|
+
}
|
|
56
|
+
return new Response(JSON.stringify(lookupBodies[lookup]), {
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error(`Unexpected network call in tests: ${url}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
66
|
+
return fetchMock;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a JSON POST request carrying the given auth headers. */
|
|
70
|
+
export function apiRequest(
|
|
71
|
+
path: string,
|
|
72
|
+
body: unknown,
|
|
73
|
+
headers: Record<string, string> = { 'X-API-Key': TEST_API_KEY },
|
|
74
|
+
) {
|
|
75
|
+
return new Request(`http://localhost${path}`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
});
|
|
80
|
+
}
|
package/test/integration.test.ts
CHANGED
|
@@ -1,152 +1,151 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting behaviour: the two accepted authentication schemes, and the
|
|
3
|
+
* phone-number formatting `verifyPhone` applies before handing off to SNS.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
7
|
import { createApp } from '../src/verify-phone-server';
|
|
8
|
+
import { apiRequest, stubNetwork, TEST_API_KEY } from './helpers';
|
|
3
9
|
|
|
4
10
|
describe('SMS Verification API Integration Tests', () => {
|
|
5
11
|
let app: ReturnType<typeof createApp>;
|
|
6
12
|
|
|
7
13
|
beforeEach(() => {
|
|
8
14
|
app = createApp(globalThis.env);
|
|
15
|
+
stubNetwork();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.unstubAllGlobals();
|
|
9
20
|
});
|
|
10
21
|
|
|
11
22
|
describe('Authentication Methods', () => {
|
|
12
23
|
it('should accept both X-API-Key and Bearer token headers', async () => {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'Authorization': 'Bearer test-api-key'
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
const res2 = await app.request(req2, globalThis.env);
|
|
34
|
-
const data2: any = await res2.json();
|
|
35
|
-
|
|
36
|
-
expect(res2.status).toBe(200);
|
|
37
|
-
expect(data2.success).toBe(true);
|
|
24
|
+
const body = { phoneNumber: '+12025550123' };
|
|
25
|
+
|
|
26
|
+
const viaHeader = await app.request(
|
|
27
|
+
apiRequest('/api/send', body, { 'X-API-Key': TEST_API_KEY }),
|
|
28
|
+
undefined,
|
|
29
|
+
globalThis.env,
|
|
30
|
+
);
|
|
31
|
+
expect(viaHeader.status).toBe(200);
|
|
32
|
+
expect(((await viaHeader.json()) as any).success).toBe(true);
|
|
33
|
+
|
|
34
|
+
const viaBearer = await app.request(
|
|
35
|
+
apiRequest('/api/send', body, { Authorization: `Bearer ${TEST_API_KEY}` }),
|
|
36
|
+
undefined,
|
|
37
|
+
globalThis.env,
|
|
38
|
+
);
|
|
39
|
+
expect(viaBearer.status).toBe(200);
|
|
40
|
+
expect(((await viaBearer.json()) as any).success).toBe(true);
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
it('should reject requests without authentication', async () => {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const res = await app.request(
|
|
45
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }, {}),
|
|
46
|
+
undefined,
|
|
47
|
+
globalThis.env,
|
|
48
|
+
);
|
|
45
49
|
const data: any = await res.json();
|
|
46
50
|
|
|
47
51
|
expect(res.status).toBe(401);
|
|
48
|
-
expect(data.error).toBe('
|
|
52
|
+
expect(data.error).toBe('Unauthorized');
|
|
53
|
+
expect(data.message).toBe('Invalid or missing API key');
|
|
49
54
|
});
|
|
50
55
|
|
|
51
56
|
it('should reject requests with invalid authentication', async () => {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
const res = await app.request(req, globalThis.env);
|
|
57
|
+
const res = await app.request(
|
|
58
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }, { 'X-API-Key': 'not-the-key' }),
|
|
59
|
+
undefined,
|
|
60
|
+
globalThis.env,
|
|
61
|
+
);
|
|
60
62
|
const data: any = await res.json();
|
|
61
63
|
|
|
62
64
|
expect(res.status).toBe(401);
|
|
63
|
-
expect(data.error).toBe('
|
|
65
|
+
expect(data.error).toBe('Unauthorized');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should not reach the SMS provider when authentication fails', async () => {
|
|
69
|
+
const fetchMock = stubNetwork();
|
|
70
|
+
|
|
71
|
+
const res = await app.request(
|
|
72
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }, {}),
|
|
73
|
+
undefined,
|
|
74
|
+
globalThis.env,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(401);
|
|
78
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
64
79
|
});
|
|
65
80
|
});
|
|
66
81
|
|
|
67
82
|
describe('Phone Number Formatting', () => {
|
|
68
83
|
it('should format various phone number formats correctly', async () => {
|
|
69
|
-
const
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'+
|
|
73
|
-
'(
|
|
74
|
-
'
|
|
84
|
+
const cases: [string, string][] = [
|
|
85
|
+
['2025550123', '+12025550123'],
|
|
86
|
+
['12025550123', '+12025550123'],
|
|
87
|
+
['+12025550123', '+12025550123'],
|
|
88
|
+
['(202) 555-0123', '+12025550123'],
|
|
89
|
+
['202-555-0123', '+12025550123'],
|
|
75
90
|
];
|
|
76
91
|
|
|
77
|
-
for (const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const res = await app.request(req, globalThis.env);
|
|
92
|
+
for (const [input, expected] of cases) {
|
|
93
|
+
const res = await app.request(
|
|
94
|
+
apiRequest('/api/send', { phoneNumber: input }),
|
|
95
|
+
undefined,
|
|
96
|
+
globalThis.env,
|
|
97
|
+
);
|
|
84
98
|
const data: any = await res.json();
|
|
85
99
|
|
|
86
|
-
expect(res.status).toBe(200);
|
|
100
|
+
expect(res.status, `${input} should be accepted`).toBe(200);
|
|
87
101
|
expect(data.success).toBe(true);
|
|
102
|
+
expect(data.phoneNumber, `${input} should normalise`).toBe(expected);
|
|
88
103
|
}
|
|
89
104
|
});
|
|
90
105
|
|
|
91
106
|
it('should reject clearly invalid phone numbers', async () => {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
for (const invalidNumber of invalidNumbers) {
|
|
101
|
-
const req = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(invalidNumber)}`, {
|
|
102
|
-
headers: {
|
|
103
|
-
'X-API-Key': 'test-api-key'
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
const res = await app.request(req, globalThis.env);
|
|
107
|
+
for (const input of ['not-a-number', '123', 'abc-def-ghij', '']) {
|
|
108
|
+
const res = await app.request(
|
|
109
|
+
apiRequest('/api/send', { phoneNumber: input }),
|
|
110
|
+
undefined,
|
|
111
|
+
globalThis.env,
|
|
112
|
+
);
|
|
107
113
|
const data: any = await res.json();
|
|
108
114
|
|
|
109
|
-
expect(res.status).toBe(400);
|
|
110
|
-
expect(data.
|
|
115
|
+
expect(res.status, `${input} should be rejected`).toBe(400);
|
|
116
|
+
expect(data.success).toBe(false);
|
|
117
|
+
expect(data.error).toBeDefined();
|
|
111
118
|
}
|
|
112
119
|
});
|
|
113
120
|
});
|
|
114
121
|
|
|
115
122
|
describe('API Endpoints', () => {
|
|
116
|
-
it('should handle send verification endpoint', async () => {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
const res = await app.request(req, globalThis.env);
|
|
123
|
+
it('should handle the send verification endpoint', async () => {
|
|
124
|
+
const res = await app.request(
|
|
125
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }),
|
|
126
|
+
undefined,
|
|
127
|
+
globalThis.env,
|
|
128
|
+
);
|
|
125
129
|
const data: any = await res.json();
|
|
126
130
|
|
|
127
131
|
expect(res.status).toBe(200);
|
|
128
132
|
expect(data).toMatchObject({
|
|
129
133
|
success: true,
|
|
130
134
|
message: 'Verification code sent successfully',
|
|
131
|
-
|
|
135
|
+
phoneNumber: '+12025550123',
|
|
132
136
|
});
|
|
133
|
-
expect(data.messageId).toBeDefined();
|
|
134
137
|
});
|
|
135
138
|
|
|
136
|
-
it('should handle verify code endpoint', async () => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
'X-API-Key': 'test-api-key'
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
const res = await app.request(req, globalThis.env);
|
|
139
|
+
it('should handle the verify code endpoint', async () => {
|
|
140
|
+
const res = await app.request(
|
|
141
|
+
apiRequest('/api/verify', { phoneNumber: '+12025550123', code: '123456' }),
|
|
142
|
+
undefined,
|
|
143
|
+
globalThis.env,
|
|
144
|
+
);
|
|
146
145
|
const data: any = await res.json();
|
|
147
146
|
|
|
148
|
-
expect(res.status).toBe(
|
|
149
|
-
expect(data
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
expect(data).toMatchObject({ success: true, verified: true });
|
|
150
149
|
});
|
|
151
150
|
});
|
|
152
|
-
});
|
|
151
|
+
});
|
package/test/server.test.ts
CHANGED
|
@@ -1,143 +1,115 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import app from '../src/verify-phone-server
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
expect(
|
|
27
|
-
expect(data.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
expect(
|
|
37
|
-
expect(data.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
expect(res.status).
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'Content-Type': 'application/json',
|
|
117
|
-
'X-API-Key': apiKey
|
|
118
|
-
},
|
|
119
|
-
body: JSON.stringify({
|
|
120
|
-
phoneNumber,
|
|
121
|
-
code
|
|
122
|
-
})
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
return response.json();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export async function sendGeneralSMS(phoneNumber, message, apiKey) {
|
|
129
|
-
const response = await fetch('http://localhost:8787/api/sms', {
|
|
130
|
-
method: 'POST',
|
|
131
|
-
headers: {
|
|
132
|
-
'Content-Type': 'application/json',
|
|
133
|
-
'X-API-Key': apiKey
|
|
134
|
-
},
|
|
135
|
-
body: JSON.stringify({
|
|
136
|
-
phoneNumber,
|
|
137
|
-
message,
|
|
138
|
-
senderId: 'MyApp'
|
|
139
|
-
})
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
return response.json();
|
|
143
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Server-level tests exercising the default export the Worker entry point
|
|
3
|
+
* (`src/index.ts`) mounts, rather than the `createApp` factory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import app from '../src/verify-phone-server';
|
|
8
|
+
import { apiRequest, stubNetwork, TEST_API_KEY } from './helpers';
|
|
9
|
+
|
|
10
|
+
describe('SMS Verification API Server', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
stubNetwork();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('Health Check Endpoints', () => {
|
|
20
|
+
it('should return API info on root endpoint', async () => {
|
|
21
|
+
const res = await app.request('http://localhost/', {}, globalThis.env);
|
|
22
|
+
const data: any = await res.json();
|
|
23
|
+
|
|
24
|
+
expect(res.status).toBe(200);
|
|
25
|
+
expect(data.success).toBe(true);
|
|
26
|
+
expect(data.message).toBe('SMS Verification API');
|
|
27
|
+
expect(data.endpoints).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return health status', async () => {
|
|
31
|
+
const res = await app.request('http://localhost/health', {}, globalThis.env);
|
|
32
|
+
const data: any = await res.json();
|
|
33
|
+
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
expect(data.success).toBe(true);
|
|
36
|
+
expect(data.status).toBe('healthy');
|
|
37
|
+
expect(data.timestamp).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('API Authentication', () => {
|
|
42
|
+
it('should require API key for protected endpoints', async () => {
|
|
43
|
+
const res = await app.request(
|
|
44
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }, {}),
|
|
45
|
+
undefined,
|
|
46
|
+
globalThis.env,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(res.status).toBe(401);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should reject a key that does not match the configured one', async () => {
|
|
53
|
+
const res = await app.request(
|
|
54
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }, {
|
|
55
|
+
'X-API-Key': 'sms_1234567890abcdef1234567890abcdef',
|
|
56
|
+
}),
|
|
57
|
+
undefined,
|
|
58
|
+
globalThis.env,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(401);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should accept valid API key', async () => {
|
|
65
|
+
const res = await app.request(
|
|
66
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }, { 'X-API-Key': TEST_API_KEY }),
|
|
67
|
+
undefined,
|
|
68
|
+
globalThis.env,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should reject every /api/* route without a key', async () => {
|
|
75
|
+
const routes: [string, unknown][] = [
|
|
76
|
+
['/api/send', { phoneNumber: '+12025550123' }],
|
|
77
|
+
['/api/verify', { phoneNumber: '+12025550123', code: '123456' }],
|
|
78
|
+
['/api/sms', { phoneNumber: '+12025550123', message: 'hello' }],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const [path, body] of routes) {
|
|
82
|
+
const res = await app.request(apiRequest(path, body, {}), undefined, globalThis.env);
|
|
83
|
+
expect(res.status, `${path} should be protected`).toBe(401);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('Documentation', () => {
|
|
89
|
+
it('should serve Swagger UI at /docs', async () => {
|
|
90
|
+
const res = await app.request('http://localhost/docs', {}, globalThis.env);
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
expect(res.headers.get('content-type')).toContain('text/html');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should serve the OpenAPI spec at /openapi.json', async () => {
|
|
97
|
+
const res = await app.request('http://localhost/openapi.json', {}, globalThis.env);
|
|
98
|
+
const data: any = await res.json();
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(data.openapi).toBe('3.0.0');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Error Handling', () => {
|
|
106
|
+
it('should return 404 for non-existent endpoints', async () => {
|
|
107
|
+
const res = await app.request('http://localhost/nonexistent', {}, globalThis.env);
|
|
108
|
+
const data: any = await res.json();
|
|
109
|
+
|
|
110
|
+
expect(res.status).toBe(404);
|
|
111
|
+
expect(data.success).toBe(false);
|
|
112
|
+
expect(data.error).toBe('Not found');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
package/test/voip.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* VoIP blocking: the `blockVoip` flag on `POST /api/send`, and the
|
|
3
|
+
* `isPhoneNumberVoip` lookup behind it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import { createApp, isPhoneNumberVoip } from '../src/verify-phone-server';
|
|
8
|
+
import { apiRequest, stubNetwork } from './helpers';
|
|
3
9
|
|
|
4
10
|
describe('VoIP Blocking Functionality', () => {
|
|
5
11
|
let app: ReturnType<typeof createApp>;
|
|
@@ -8,106 +14,93 @@ describe('VoIP Blocking Functionality', () => {
|
|
|
8
14
|
app = createApp(globalThis.env);
|
|
9
15
|
});
|
|
10
16
|
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
});
|
|
20
|
+
|
|
11
21
|
describe('blockVoip parameter', () => {
|
|
12
|
-
it('should
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
it('should skip the lookup entirely when blockVoip is not specified', async () => {
|
|
23
|
+
const fetchMock = stubNetwork({ lookup: 'voip' });
|
|
24
|
+
|
|
25
|
+
const res = await app.request(
|
|
26
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123' }),
|
|
27
|
+
undefined,
|
|
28
|
+
globalThis.env,
|
|
29
|
+
);
|
|
19
30
|
const data: any = await res.json();
|
|
20
31
|
|
|
21
32
|
expect(res.status).toBe(200);
|
|
22
33
|
expect(data.success).toBe(true);
|
|
34
|
+
const lookups = fetchMock.mock.calls.filter(([url]) => String(url).includes('phone-lookup'));
|
|
35
|
+
expect(lookups).toHaveLength(0);
|
|
23
36
|
});
|
|
24
37
|
|
|
25
38
|
it('should allow requests when blockVoip is false', async () => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
stubNetwork({ lookup: 'voip' });
|
|
40
|
+
|
|
41
|
+
const res = await app.request(
|
|
42
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123', blockVoip: false }),
|
|
43
|
+
undefined,
|
|
44
|
+
globalThis.env,
|
|
45
|
+
);
|
|
32
46
|
const data: any = await res.json();
|
|
33
47
|
|
|
34
48
|
expect(res.status).toBe(200);
|
|
35
49
|
expect(data.success).toBe(true);
|
|
36
50
|
});
|
|
37
51
|
|
|
38
|
-
it('should
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
it('should send when blockVoip is true and the number is not VoIP', async () => {
|
|
53
|
+
stubNetwork({ lookup: 'landline' });
|
|
54
|
+
|
|
55
|
+
const res = await app.request(
|
|
56
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123', blockVoip: true }),
|
|
57
|
+
undefined,
|
|
58
|
+
globalThis.env,
|
|
59
|
+
);
|
|
45
60
|
const data: any = await res.json();
|
|
46
61
|
|
|
47
62
|
expect(res.status).toBe(200);
|
|
48
63
|
expect(data.success).toBe(true);
|
|
49
64
|
});
|
|
65
|
+
|
|
66
|
+
it('should reject when blockVoip is true and the number is VoIP', async () => {
|
|
67
|
+
stubNetwork({ lookup: 'voip' });
|
|
68
|
+
|
|
69
|
+
const res = await app.request(
|
|
70
|
+
apiRequest('/api/send', { phoneNumber: '+12025550123', blockVoip: true }),
|
|
71
|
+
undefined,
|
|
72
|
+
globalThis.env,
|
|
73
|
+
);
|
|
74
|
+
const data: any = await res.json();
|
|
75
|
+
|
|
76
|
+
expect(res.status).toBe(400);
|
|
77
|
+
expect(data).toMatchObject({
|
|
78
|
+
success: false,
|
|
79
|
+
error: 'VoIP numbers are not allowed',
|
|
80
|
+
isVoip: true,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
50
83
|
});
|
|
51
84
|
|
|
52
85
|
describe('VoIP detection logic', () => {
|
|
53
|
-
it('should
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// Test VoIP number
|
|
76
|
-
const isVoip = await isPhoneNumberVoip('+1234567890');
|
|
77
|
-
expect(isVoip).toBe(true);
|
|
78
|
-
|
|
79
|
-
// Mock the grab function to return mobile data
|
|
80
|
-
g.grab = {
|
|
81
|
-
instance: () => ({
|
|
82
|
-
"phone-lookup": async () => ({
|
|
83
|
-
carrier: {
|
|
84
|
-
name: 'Verizon Wireless',
|
|
85
|
-
type: 'mobile'
|
|
86
|
-
},
|
|
87
|
-
portability: {
|
|
88
|
-
line_type: 'mobile'
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
// Test mobile number
|
|
95
|
-
const isMobile = await isPhoneNumberVoip('+1234567890');
|
|
96
|
-
expect(isMobile).toBe(false);
|
|
97
|
-
|
|
98
|
-
// Mock the grab function to return null data
|
|
99
|
-
g.grab = {
|
|
100
|
-
instance: () => ({
|
|
101
|
-
"phone-lookup": async () => null
|
|
102
|
-
})
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// Test null data
|
|
106
|
-
const isNull = await isPhoneNumberVoip('+1234567890');
|
|
107
|
-
expect(isNull).toBe(false);
|
|
108
|
-
|
|
109
|
-
// Restore original grab function
|
|
110
|
-
g.grab = originalGrab;
|
|
86
|
+
it('should report a Bandwidth/VoIP carrier as VoIP', async () => {
|
|
87
|
+
stubNetwork({ lookup: 'voip' });
|
|
88
|
+
await expect(isPhoneNumberVoip('+12025550123')).resolves.toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should report a landline carrier as not VoIP', async () => {
|
|
92
|
+
stubNetwork({ lookup: 'landline' });
|
|
93
|
+
await expect(isPhoneNumberVoip('+12025550123')).resolves.toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should treat an empty lookup response as not VoIP', async () => {
|
|
97
|
+
stubNetwork({ lookup: 'none' });
|
|
98
|
+
await expect(isPhoneNumberVoip('+12025550123')).resolves.toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should default to allowing the number when the lookup fails', async () => {
|
|
102
|
+
stubNetwork({ lookupStatus: 429 });
|
|
103
|
+
await expect(isPhoneNumberVoip('+12025550123')).resolves.toBe(false);
|
|
111
104
|
});
|
|
112
105
|
});
|
|
113
|
-
});
|
|
106
|
+
});
|