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 CHANGED
@@ -252,7 +252,9 @@ X-API-Key: your_api_key
252
252
 
253
253
  ## API Documentation
254
254
 
255
- Visit `/docs` to see the interactive OpenAPI documentation.
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.76",
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": "^0.19.9",
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("/docs", {
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: "/docs" }));
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
- import { describe, it, expect, beforeEach } from 'vitest';
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
- describe('Health Check', () => {
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 req = new Request('http://localhost/health');
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
- status: 'ok'
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 = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890');
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
- error: 'API key required'
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 = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890', {
44
- headers: {
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 = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890', {
59
- headers: {
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 = new Request('http://localhost/api/send-verification?phoneNumber=invalid-phone', {
72
- headers: {
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).toMatchObject({
81
- error: 'Invalid phone number format'
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 correctly', async () => {
86
- const req = new Request('http://localhost/api/send-verification?phoneNumber=1234567890', {
87
- headers: {
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 accept blockVoip parameter', async () => {
99
- const req = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890&blockVoip=true', {
100
- headers: {
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.success).toBe(true);
131
+ expect(data.code).toMatch(/^\d{6}$/);
132
+ expect(data.expiresIn).toBe(600);
109
133
  });
110
134
 
111
- it('should handle blockVoip=false parameter', async () => {
112
- const req = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890&blockVoip=false', {
113
- headers: {
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.success).toBe(true);
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 = new Request('http://localhost/api/verify-code?phoneNumber=%2B1234567890&code=123456');
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 = new Request('http://localhost/api/verify-code?phoneNumber=%2B1234567890&code=123456', {
139
- headers: {
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 reject invalid phone number format', async () => {
153
- const req = new Request('http://localhost/api/verify-code?phoneNumber=invalid-phone&code=123456', {
154
- headers: {
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(400);
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
- it('should accept Bearer token authentication', async () => {
168
- const req = new Request('http://localhost/api/verify-code?phoneNumber=%2B1234567890&code=123456', {
169
- headers: {
170
- 'Authorization': 'Bearer test-api-key'
171
- }
172
- });
173
- const res = await app.request(req, globalThis.env);
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(400); // Will fail because no code was sent, but auth works
177
- expect(data.error).toBeDefined();
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 documentation', async () => {
183
- const req = new Request('http://localhost/openapi.json');
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
- openapi: '3.0.0',
190
- info: {
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 req = new Request('http://localhost/');
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
+ });
@@ -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
+ }
@@ -1,152 +1,151 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
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 phoneNumber = '+1234567890';
14
-
15
- // Test X-API-Key header
16
- const req1 = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(phoneNumber)}`, {
17
- headers: {
18
- 'X-API-Key': 'test-api-key'
19
- }
20
- });
21
- const res1 = await app.request(req1, globalThis.env);
22
- const data1: any = await res1.json();
23
-
24
- expect(res1.status).toBe(200);
25
- expect(data1.success).toBe(true);
26
-
27
- // Test Bearer token header
28
- const req2 = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(phoneNumber)}`, {
29
- headers: {
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 phoneNumber = '+1234567890';
42
-
43
- const req = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(phoneNumber)}`);
44
- const res = await app.request(req, globalThis.env);
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('API key required');
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 phoneNumber = '+1234567890';
53
-
54
- const req = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(phoneNumber)}`, {
55
- headers: {
56
- 'X-API-Key': 'invalid-key'
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('Invalid API key');
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 testCases = [
70
- '1234567890',
71
- '11234567890',
72
- '+1234567890',
73
- '(123) 456-7890',
74
- '123-456-7890',
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 phoneNumber of testCases) {
78
- const req = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(phoneNumber)}`, {
79
- headers: {
80
- 'X-API-Key': 'test-api-key'
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 invalidNumbers = [
93
- 'not-a-number',
94
- '123',
95
- '12345678901234567890', // Too long
96
- '+', // Just plus sign
97
- 'abc123def',
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.error).toBe('Invalid phone number format');
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 phoneNumber = '+1234567890';
118
-
119
- const req = new Request(`http://localhost/api/send-verification?phoneNumber=${encodeURIComponent(phoneNumber)}`, {
120
- headers: {
121
- 'X-API-Key': 'test-api-key'
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
- expiresIn: 600
135
+ phoneNumber: '+12025550123',
132
136
  });
133
- expect(data.messageId).toBeDefined();
134
137
  });
135
138
 
136
- it('should handle verify code endpoint', async () => {
137
- const phoneNumber = '+1234567890';
138
- const code = '123456';
139
-
140
- const req = new Request(`http://localhost/api/verify-code?phoneNumber=${encodeURIComponent(phoneNumber)}&code=${code}`, {
141
- headers: {
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(400); // Will fail because no code was sent, but endpoint works
149
- expect(data.error).toBeDefined();
147
+ expect(res.status).toBe(200);
148
+ expect(data).toMatchObject({ success: true, verified: true });
150
149
  });
151
150
  });
152
- });
151
+ });
@@ -1,143 +1,115 @@
1
- /**
2
- * Test file for the SMS Verification API server
3
- */
4
-
5
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6
- import { Hono } from 'hono';
7
- import app from '../src/verify-phone-server.js';
8
-
9
- describe('SMS Verification API Server', () => {
10
- let server;
11
-
12
- beforeAll(() => {
13
- server = new Hono();
14
- server.route('/', app);
15
- });
16
-
17
- afterAll(() => {
18
- // Cleanup if needed
19
- });
20
-
21
- describe('Health Check Endpoints', () => {
22
- it('should return API info on root endpoint', async () => {
23
- const res = await server.request('/');
24
- const data: any = await res.json();
25
-
26
- expect(res.status).toBe(200);
27
- expect(data.success).toBe(true);
28
- expect(data.message).toBe('SMS Verification API');
29
- expect(data.endpoints).toBeDefined();
30
- });
31
-
32
- it('should return health status', async () => {
33
- const res = await server.request('/health');
34
- const data: any = await res.json();
35
-
36
- expect(res.status).toBe(200);
37
- expect(data.success).toBe(true);
38
- expect(data.status).toBe('healthy');
39
- expect(data.timestamp).toBeDefined();
40
- });
41
- });
42
-
43
- describe('API Authentication', () => {
44
- it('should require API key for protected endpoints', async () => {
45
- const res = await server.request('/api/send', {
46
- method: 'POST',
47
- headers: {
48
- 'Content-Type': 'application/json',
49
- },
50
- body: JSON.stringify({
51
- phoneNumber: '+1234567890'
52
- })
53
- });
54
-
55
- expect(res.status).toBe(401);
56
- });
57
-
58
- it('should accept valid API key', async () => {
59
- const res = await server.request('/api/send', {
60
- method: 'POST',
61
- headers: {
62
- 'Content-Type': 'application/json',
63
- 'X-API-Key': 'sms_1234567890abcdef1234567890abcdef'
64
- },
65
- body: JSON.stringify({
66
- phoneNumber: '+1234567890'
67
- })
68
- });
69
-
70
- // Should not be 401 (unauthorized) - might be 500 due to missing AWS credentials
71
- expect(res.status).not.toBe(401);
72
- });
73
- });
74
-
75
- describe('Documentation', () => {
76
- it('should serve OpenAPI documentation', async () => {
77
- const res = await server.request('/docs');
78
- expect(res.status).toBe(200);
79
- });
80
- });
81
-
82
- describe('Error Handling', () => {
83
- it('should return 404 for non-existent endpoints', async () => {
84
- const res = await server.request('/nonexistent');
85
- const data: any = await res.json();
86
-
87
- expect(res.status).toBe(404);
88
- expect(data.success).toBe(false);
89
- expect(data.error).toBe('Not found');
90
- });
91
- });
92
- });
93
-
94
- // Example usage functions
95
- export async function sendVerificationCode(phoneNumber, apiKey) {
96
- const response = await fetch('http://localhost:8787/api/send', {
97
- method: 'POST',
98
- headers: {
99
- 'Content-Type': 'application/json',
100
- 'X-API-Key': apiKey
101
- },
102
- body: JSON.stringify({
103
- phoneNumber,
104
- blockVoip: true,
105
- senderId: 'MyApp'
106
- })
107
- });
108
-
109
- return response.json();
110
- }
111
-
112
- export async function verifyCode(phoneNumber, code, apiKey) {
113
- const response = await fetch('http://localhost:8787/api/verify', {
114
- method: 'POST',
115
- headers: {
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
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { createApp } from '../src/verify-phone-server';
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 allow requests when blockVoip is not specified', async () => {
13
- const req = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890', {
14
- headers: {
15
- 'X-API-Key': 'test-api-key'
16
- }
17
- });
18
- const res = await app.request(req, globalThis.env);
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
- const req = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890&blockVoip=false', {
27
- headers: {
28
- 'X-API-Key': 'test-api-key'
29
- }
30
- });
31
- const res = await app.request(req, globalThis.env);
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 accept blockVoip=true parameter', async () => {
39
- const req = new Request('http://localhost/api/send-verification?phoneNumber=%2B1234567890&blockVoip=true', {
40
- headers: {
41
- 'X-API-Key': 'test-api-key'
42
- }
43
- });
44
- const res = await app.request(req, globalThis.env);
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 correctly identify VoIP numbers', async () => {
54
- // Import the function from the module
55
- const module = await import('../src/verify-phone-server');
56
- const { isPhoneNumberVoip } = module;
57
-
58
- // Mock the grab function to return VoIP data
59
- const g = globalThis as any;
60
- const originalGrab = g.grab;
61
- g.grab = {
62
- instance: () => ({
63
- "phone-lookup": async () => ({
64
- carrier: {
65
- name: 'Bandwidth',
66
- type: 'voip'
67
- },
68
- portability: {
69
- line_type: 'landline'
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
+ });