@prosopo/ipinfo 0.4.13 → 0.4.14
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/.turbo/turbo-build$colon$cjs.log +2 -5
- package/.turbo/turbo-build$colon$tsc.log +10 -13
- package/.turbo/turbo-build.log +4 -10
- package/CHANGELOG.md +12 -0
- package/LICENSE +201 -0
- package/package.json +48 -49
- package/src/IpInfoService.ts +0 -185
- package/src/backends/ipapi.ts +0 -196
- package/src/backends/maxmind.ts +0 -336
- package/src/index.ts +0 -24
- package/src/tests/ipInfoService.unit.test.ts +0 -481
- package/src/tests/ipapi.unit.test.ts +0 -509
- package/src/tests/ipinfo.test-d.ts +0 -189
- package/src/tests/maxmind.unit.test.ts +0 -801
- package/src/types.ts +0 -36
- package/tsconfig.cjs.json +0 -24
- package/tsconfig.json +0 -25
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
|
@@ -1,481 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
// The service is pure routing: which backend answers, in what order, and what
|
|
16
|
-
// happens when one of them declines. The backends are therefore injected as
|
|
17
|
-
// stubs whose availability and answers each test controls directly — using the
|
|
18
|
-
// real ones would put a network call and a database file between the test and
|
|
19
|
-
// the branch it is trying to reach.
|
|
20
|
-
|
|
21
|
-
import type { IPInfoResponse, IPInfoResult } from "@prosopo/types";
|
|
22
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
-
import {
|
|
24
|
-
type IpInfoBackends,
|
|
25
|
-
IpInfoService,
|
|
26
|
-
isNonRoutable,
|
|
27
|
-
} from "../IpInfoService.js";
|
|
28
|
-
import type { IpapiBackend } from "../backends/ipapi.js";
|
|
29
|
-
import type { MaxMindBackend } from "../backends/maxmind.js";
|
|
30
|
-
import type { IpInfoServiceConfig } from "../types.js";
|
|
31
|
-
|
|
32
|
-
const IP = "8.8.8.8";
|
|
33
|
-
|
|
34
|
-
const result = (source: string): IPInfoResult => ({
|
|
35
|
-
isValid: true,
|
|
36
|
-
ip: IP,
|
|
37
|
-
countryCode: source,
|
|
38
|
-
isVPN: false,
|
|
39
|
-
isTor: false,
|
|
40
|
-
isProxy: false,
|
|
41
|
-
isDatacenter: false,
|
|
42
|
-
isAbuser: false,
|
|
43
|
-
isMobile: false,
|
|
44
|
-
isSatellite: false,
|
|
45
|
-
isCrawler: false,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const failure = (error: string): IPInfoResponse => ({
|
|
49
|
-
isValid: false,
|
|
50
|
-
error,
|
|
51
|
-
ip: IP,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
interface StubBackend {
|
|
55
|
-
initialize: () => Promise<void>;
|
|
56
|
-
isAvailable: () => boolean;
|
|
57
|
-
lookup: (ip: string) => Promise<IPInfoResponse>;
|
|
58
|
-
countryCode: (ip: string) => string | undefined;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const stub = (
|
|
62
|
-
available: boolean,
|
|
63
|
-
answer: IPInfoResponse = result("stub"),
|
|
64
|
-
countryCode: string | undefined = "US",
|
|
65
|
-
): StubBackend => ({
|
|
66
|
-
initialize: vi.fn<() => Promise<void>>(async () => {}),
|
|
67
|
-
isAvailable: vi.fn<() => boolean>(() => available),
|
|
68
|
-
lookup: vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () => answer),
|
|
69
|
-
countryCode: vi.fn<(ip: string) => string | undefined>(() => countryCode),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The stubs implement the whole interface the service uses, but not the private
|
|
74
|
-
* fields of the concrete classes, so the injection point needs a cast. Confined
|
|
75
|
-
* to this one helper rather than repeated at every call site.
|
|
76
|
-
*/
|
|
77
|
-
const service = (
|
|
78
|
-
backends: { maxmind?: StubBackend; ipapi?: StubBackend },
|
|
79
|
-
config: IpInfoServiceConfig = {},
|
|
80
|
-
): IpInfoService =>
|
|
81
|
-
new IpInfoService(config, {
|
|
82
|
-
maxmind: backends.maxmind as unknown as MaxMindBackend,
|
|
83
|
-
ipapi: backends.ipapi as unknown as IpapiBackend,
|
|
84
|
-
} satisfies IpInfoBackends);
|
|
85
|
-
|
|
86
|
-
describe("isNonRoutable", () => {
|
|
87
|
-
it("rejects the IPv4 loopback and private ranges", () => {
|
|
88
|
-
for (const ip of [
|
|
89
|
-
"127.0.0.1",
|
|
90
|
-
"127.255.255.255",
|
|
91
|
-
"10.0.0.1",
|
|
92
|
-
"192.168.1.1",
|
|
93
|
-
"169.254.1.1",
|
|
94
|
-
"0.0.0.0",
|
|
95
|
-
]) {
|
|
96
|
-
expect(isNonRoutable(ip), ip).toBe(true);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("rejects the whole 172.16/12 block and nothing either side of it", () => {
|
|
101
|
-
// The boundaries matter: 172.15 and 172.32 are public address space, and
|
|
102
|
-
// treating them as private would silently blind the service to them.
|
|
103
|
-
expect(isNonRoutable("172.16.0.0")).toBe(true);
|
|
104
|
-
expect(isNonRoutable("172.31.255.255")).toBe(true);
|
|
105
|
-
expect(isNonRoutable("172.15.0.1")).toBe(false);
|
|
106
|
-
expect(isNonRoutable("172.32.0.1")).toBe(false);
|
|
107
|
-
expect(isNonRoutable("172.217.16.1")).toBe(false);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("treats a malformed 172. address as routable rather than crashing", () => {
|
|
111
|
-
// parseInt of a missing or non-numeric octet yields NaN, and every
|
|
112
|
-
// comparison against NaN is false — so this must fall through, not throw.
|
|
113
|
-
expect(isNonRoutable("172.")).toBe(false);
|
|
114
|
-
expect(isNonRoutable("172.abc.0.1")).toBe(false);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("sees through the IPv4-mapped IPv6 prefix", () => {
|
|
118
|
-
// A request arriving on a dual-stack socket presents 127.0.0.1 in this
|
|
119
|
-
// form; missing it would send loopback traffic to a paid upstream.
|
|
120
|
-
expect(isNonRoutable("::ffff:127.0.0.1")).toBe(true);
|
|
121
|
-
expect(isNonRoutable("::FFFF:192.168.0.1")).toBe(true);
|
|
122
|
-
expect(isNonRoutable("::ffff:8.8.8.8")).toBe(false);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("rejects the IPv6 loopback, unspecified, ULA and link-local ranges", () => {
|
|
126
|
-
for (const ip of [
|
|
127
|
-
"::1",
|
|
128
|
-
"::",
|
|
129
|
-
"fc00::1",
|
|
130
|
-
"fd12:3456::1",
|
|
131
|
-
"FD00::1",
|
|
132
|
-
"fe80::1",
|
|
133
|
-
"feb0::1",
|
|
134
|
-
"FE80::1",
|
|
135
|
-
]) {
|
|
136
|
-
expect(isNonRoutable(ip), ip).toBe(true);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("accepts public IPv4 and IPv6 addresses", () => {
|
|
141
|
-
for (const ip of [
|
|
142
|
-
"8.8.8.8",
|
|
143
|
-
"1.1.1.1",
|
|
144
|
-
"2001:4860:4860::8888",
|
|
145
|
-
"fec0::1",
|
|
146
|
-
]) {
|
|
147
|
-
expect(isNonRoutable(ip), ip).toBe(false);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("treats an empty string as routable", () => {
|
|
152
|
-
// Not this function's job to validate: the backends reject it, and
|
|
153
|
-
// claiming "non-routable" would hide a caller bug behind a plausible
|
|
154
|
-
// looking answer.
|
|
155
|
-
expect(isNonRoutable("")).toBe(false);
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe("IpInfoService construction", () => {
|
|
160
|
-
it("builds no backends when nothing is configured", () => {
|
|
161
|
-
expect(new IpInfoService({}).isAvailable()).toBe(false);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("builds MaxMind from either database path alone", async () => {
|
|
165
|
-
// Only one of the two databases being licensed is a normal deployment.
|
|
166
|
-
const city = new IpInfoService({ maxmindCityDbPath: "/city.mmdb" });
|
|
167
|
-
const asn = new IpInfoService({ maxmindAsnDbPath: "/asn.mmdb" });
|
|
168
|
-
|
|
169
|
-
// Unopened, so still unavailable — but constructed, which the lookup
|
|
170
|
-
// error message distinguishes.
|
|
171
|
-
expect(await city.lookup(IP)).toEqual(
|
|
172
|
-
failure("No IP info backend available"),
|
|
173
|
-
);
|
|
174
|
-
expect(await asn.lookup(IP)).toEqual(
|
|
175
|
-
failure("No IP info backend available"),
|
|
176
|
-
);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it("builds ipapi when a url is configured", () => {
|
|
180
|
-
expect(
|
|
181
|
-
new IpInfoService({ ipapiUrl: "https://api.ipapi.is" }).isAvailable(),
|
|
182
|
-
).toBe(true);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("does not build ipapi from a key with no url", () => {
|
|
186
|
-
// A key without an endpoint is a half-finished configuration; guessing a
|
|
187
|
-
// default endpoint would send credentials somewhere never asked for.
|
|
188
|
-
expect(new IpInfoService({ ipapiKey: "secret" }).isAvailable()).toBe(false);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("uses injected backends in place of configured ones", () => {
|
|
192
|
-
// Injection must win outright: a config that would otherwise build a real
|
|
193
|
-
// ipapi backend must not leave a second one behind.
|
|
194
|
-
const ipapi = stub(false);
|
|
195
|
-
const svc = service({ ipapi }, { ipapiUrl: "https://api.ipapi.is" });
|
|
196
|
-
|
|
197
|
-
expect(svc.isAvailable()).toBe(false);
|
|
198
|
-
expect(ipapi.isAvailable).toHaveBeenCalled();
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe("IpInfoService.initialize", () => {
|
|
203
|
-
it("initializes MaxMind and leaves ipapi alone", async () => {
|
|
204
|
-
// ipapi is stateless — there is nothing to open — so an initialize call
|
|
205
|
-
// against it would be dead work on every boot.
|
|
206
|
-
const maxmind = stub(true);
|
|
207
|
-
const ipapi = stub(true);
|
|
208
|
-
|
|
209
|
-
await service({ maxmind, ipapi }).initialize();
|
|
210
|
-
|
|
211
|
-
expect(maxmind.initialize).toHaveBeenCalledTimes(1);
|
|
212
|
-
expect(ipapi.initialize).not.toHaveBeenCalled();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("succeeds when there are no backends at all", async () => {
|
|
216
|
-
await expect(new IpInfoService({}).initialize()).resolves.toBeUndefined();
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("logs the availability of both backends", async () => {
|
|
220
|
-
const info = vi.fn();
|
|
221
|
-
await service(
|
|
222
|
-
{ maxmind: stub(true), ipapi: stub(false) },
|
|
223
|
-
{ logger: { info } as never },
|
|
224
|
-
).initialize();
|
|
225
|
-
|
|
226
|
-
expect(info).toHaveBeenCalledTimes(1);
|
|
227
|
-
const entry = info.mock.calls[0]?.[0];
|
|
228
|
-
expect(typeof entry).toBe("function");
|
|
229
|
-
expect(entry()).toMatchObject({
|
|
230
|
-
data: { maxmindAvailable: true, ipapiAvailable: false },
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("propagates a MaxMind initialize failure", async () => {
|
|
235
|
-
// MaxMind swallows its own open failures, so anything reaching here is
|
|
236
|
-
// unexpected and must surface at boot rather than as a silent lookup gap.
|
|
237
|
-
const maxmind = stub(true);
|
|
238
|
-
maxmind.initialize = vi.fn<() => Promise<void>>(async () => {
|
|
239
|
-
throw new Error("disk on fire");
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
await expect(service({ maxmind }).initialize()).rejects.toThrow(
|
|
243
|
-
"disk on fire",
|
|
244
|
-
);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe("IpInfoService.isAvailable", () => {
|
|
249
|
-
it.each([
|
|
250
|
-
[true, true, true],
|
|
251
|
-
[true, false, true],
|
|
252
|
-
[false, true, true],
|
|
253
|
-
[false, false, false],
|
|
254
|
-
])(
|
|
255
|
-
"maxmind=%s ipapi=%s -> %s",
|
|
256
|
-
(maxmindUp: boolean, ipapiUp: boolean, expected: boolean) => {
|
|
257
|
-
expect(
|
|
258
|
-
service({
|
|
259
|
-
maxmind: stub(maxmindUp),
|
|
260
|
-
ipapi: stub(ipapiUp),
|
|
261
|
-
}).isAvailable(),
|
|
262
|
-
).toBe(expected);
|
|
263
|
-
},
|
|
264
|
-
);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
describe("IpInfoService.lookup", () => {
|
|
268
|
-
let maxmind: StubBackend;
|
|
269
|
-
let ipapi: StubBackend;
|
|
270
|
-
|
|
271
|
-
beforeEach(() => {
|
|
272
|
-
maxmind = stub(true, result("maxmind"));
|
|
273
|
-
ipapi = stub(true, result("ipapi"));
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("short-circuits a non-routable IP without touching a backend", async () => {
|
|
277
|
-
// The important half of this assertion is the second: a loopback lookup
|
|
278
|
-
// must not spend a paid ipapi credit.
|
|
279
|
-
await expect(
|
|
280
|
-
service({ maxmind, ipapi }).lookup("127.0.0.1"),
|
|
281
|
-
).resolves.toEqual({
|
|
282
|
-
isValid: false,
|
|
283
|
-
error: "Non-routable IP address",
|
|
284
|
-
ip: "127.0.0.1",
|
|
285
|
-
});
|
|
286
|
-
expect(ipapi.lookup).not.toHaveBeenCalled();
|
|
287
|
-
expect(maxmind.lookup).not.toHaveBeenCalled();
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("prefers ipapi when both are available", async () => {
|
|
291
|
-
// ipapi carries the threat data MaxMind's free databases do not.
|
|
292
|
-
const response = await service({ maxmind, ipapi }).lookup(IP);
|
|
293
|
-
|
|
294
|
-
expect(response).toEqual(result("ipapi"));
|
|
295
|
-
expect(maxmind.lookup).not.toHaveBeenCalled();
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it("falls back to MaxMind when ipapi returns an invalid result", async () => {
|
|
299
|
-
ipapi = stub(true, failure("upstream 503"));
|
|
300
|
-
|
|
301
|
-
await expect(service({ maxmind, ipapi }).lookup(IP)).resolves.toEqual(
|
|
302
|
-
result("maxmind"),
|
|
303
|
-
);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("returns the ipapi error when MaxMind cannot cover for it", async () => {
|
|
307
|
-
// Reporting the real upstream error beats a generic "no backend" message
|
|
308
|
-
// that would send someone looking at the wrong system.
|
|
309
|
-
ipapi = stub(true, failure("upstream 503"));
|
|
310
|
-
|
|
311
|
-
await expect(
|
|
312
|
-
service({ maxmind: stub(false), ipapi }).lookup(IP),
|
|
313
|
-
).resolves.toEqual(failure("upstream 503"));
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it("returns the ipapi error when MaxMind is not configured at all", async () => {
|
|
317
|
-
ipapi = stub(true, failure("upstream 503"));
|
|
318
|
-
|
|
319
|
-
await expect(service({ ipapi }).lookup(IP)).resolves.toEqual(
|
|
320
|
-
failure("upstream 503"),
|
|
321
|
-
);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("logs the fallback with the upstream error attached", async () => {
|
|
325
|
-
const debug = vi.fn();
|
|
326
|
-
ipapi = stub(true, failure("upstream 503"));
|
|
327
|
-
|
|
328
|
-
await service({ maxmind, ipapi }, { logger: { debug } as never }).lookup(
|
|
329
|
-
IP,
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
expect(debug).toHaveBeenCalledTimes(1);
|
|
333
|
-
expect(debug.mock.calls[0]?.[0]()).toMatchObject({
|
|
334
|
-
data: { ip: IP, error: "upstream 503" },
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
it("logs 'unknown' when the failed result carries no error field", async () => {
|
|
339
|
-
// The result type permits it; the log line must stay well-formed rather
|
|
340
|
-
// than printing undefined.
|
|
341
|
-
const debug = vi.fn();
|
|
342
|
-
ipapi = stub(true, { isValid: false, ip: IP } as IPInfoResponse);
|
|
343
|
-
|
|
344
|
-
await service({ maxmind, ipapi }, { logger: { debug } as never }).lookup(
|
|
345
|
-
IP,
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
expect(debug.mock.calls[0]?.[0]()).toMatchObject({
|
|
349
|
-
data: { error: "unknown" },
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("does not log a fallback when ipapi succeeds", async () => {
|
|
354
|
-
const debug = vi.fn();
|
|
355
|
-
|
|
356
|
-
await service({ maxmind, ipapi }, { logger: { debug } as never }).lookup(
|
|
357
|
-
IP,
|
|
358
|
-
);
|
|
359
|
-
|
|
360
|
-
expect(debug).not.toHaveBeenCalled();
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("uses MaxMind alone when ipapi is unavailable", async () => {
|
|
364
|
-
// Unavailable is checked before the call, so no failed request is made.
|
|
365
|
-
const down = stub(false);
|
|
366
|
-
const response = await service({ maxmind, ipapi: down }).lookup(IP);
|
|
367
|
-
|
|
368
|
-
expect(response).toEqual(result("maxmind"));
|
|
369
|
-
expect(down.lookup).not.toHaveBeenCalled();
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
it("reports that no backend is available when neither is", async () => {
|
|
373
|
-
await expect(
|
|
374
|
-
service({ maxmind: stub(false), ipapi: stub(false) }).lookup(IP),
|
|
375
|
-
).resolves.toEqual(failure("No IP info backend available"));
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it("reports that no backend is available when none is configured", async () => {
|
|
379
|
-
await expect(new IpInfoService({}).lookup(IP)).resolves.toEqual(
|
|
380
|
-
failure("No IP info backend available"),
|
|
381
|
-
);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it("passes the IP through to the backend unmodified", async () => {
|
|
385
|
-
// Normalisation happens inside isNonRoutable only; the backend must see
|
|
386
|
-
// exactly what the caller supplied.
|
|
387
|
-
await service({ ipapi }).lookup("2001:4860:4860::8888");
|
|
388
|
-
|
|
389
|
-
expect(ipapi.lookup).toHaveBeenCalledWith("2001:4860:4860::8888");
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it("does not catch a backend that throws", async () => {
|
|
393
|
-
// Deliberate: both backends convert their own failures into invalid
|
|
394
|
-
// results, so a throw reaching here is a bug and must not be disguised as
|
|
395
|
-
// a routine lookup miss.
|
|
396
|
-
ipapi.lookup = vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () => {
|
|
397
|
-
throw new Error("unexpected");
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
await expect(service({ maxmind, ipapi }).lookup(IP)).rejects.toThrow(
|
|
401
|
-
"unexpected",
|
|
402
|
-
);
|
|
403
|
-
expect(maxmind.lookup).not.toHaveBeenCalled();
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
it("re-checks availability on every lookup", async () => {
|
|
407
|
-
// A backend can come up between calls; the answer must not be cached at
|
|
408
|
-
// construction time.
|
|
409
|
-
let up = false;
|
|
410
|
-
const flaky: StubBackend = {
|
|
411
|
-
initialize: vi.fn<() => Promise<void>>(async () => {}),
|
|
412
|
-
isAvailable: vi.fn<() => boolean>(() => up),
|
|
413
|
-
lookup: vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () =>
|
|
414
|
-
result("recovered"),
|
|
415
|
-
),
|
|
416
|
-
countryCode: vi.fn<(ip: string) => string | undefined>(() => undefined),
|
|
417
|
-
};
|
|
418
|
-
const svc = service({ ipapi: flaky });
|
|
419
|
-
|
|
420
|
-
expect(await svc.lookup(IP)).toEqual(
|
|
421
|
-
failure("No IP info backend available"),
|
|
422
|
-
);
|
|
423
|
-
up = true;
|
|
424
|
-
expect(await svc.lookup(IP)).toEqual(result("recovered"));
|
|
425
|
-
});
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
describe("IpInfoService.country", () => {
|
|
429
|
-
it("answers from MaxMind", () => {
|
|
430
|
-
expect(
|
|
431
|
-
service({ maxmind: stub(true, result("maxmind"), "GB") }).country(IP),
|
|
432
|
-
).toBe("GB");
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
it("never consults ipapi, even when ipapi is the preferred backend", () => {
|
|
436
|
-
// `lookup()` prefers ipapi for its threat data, which costs a call to
|
|
437
|
-
// the sidecar. A country lookup does not need that data and must not
|
|
438
|
-
// pay for it.
|
|
439
|
-
const ipapi = stub(true, result("ipapi"));
|
|
440
|
-
const maxmind = stub(true, result("maxmind"), "FR");
|
|
441
|
-
|
|
442
|
-
expect(service({ maxmind, ipapi }).country(IP)).toBe("FR");
|
|
443
|
-
|
|
444
|
-
expect(ipapi.lookup).not.toHaveBeenCalled();
|
|
445
|
-
expect(ipapi.isAvailable).not.toHaveBeenCalled();
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
it("has no answer for a loopback caller, without reading the database", () => {
|
|
449
|
-
// Deploy gates and container health checks call over loopback; they
|
|
450
|
-
// must cost nothing and must never resolve to a country.
|
|
451
|
-
const maxmind = stub(true, result("maxmind"), "US");
|
|
452
|
-
|
|
453
|
-
expect(service({ maxmind }).country("127.0.0.1")).toBeUndefined();
|
|
454
|
-
expect(service({ maxmind }).country("::1")).toBeUndefined();
|
|
455
|
-
expect(service({ maxmind }).country("10.1.2.3")).toBeUndefined();
|
|
456
|
-
|
|
457
|
-
expect(maxmind.countryCode).not.toHaveBeenCalled();
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it("has no answer when no MaxMind backend is configured", () => {
|
|
461
|
-
expect(service({ ipapi: stub(true) }).country(IP)).toBeUndefined();
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it("has no answer while MaxMind is still unavailable", () => {
|
|
465
|
-
// Availability is false until the environment finishes becoming ready,
|
|
466
|
-
// which is exactly when a caller that cannot await readiness asks.
|
|
467
|
-
const maxmind = stub(false, result("maxmind"), "DE");
|
|
468
|
-
|
|
469
|
-
expect(service({ maxmind }).country(IP)).toBeUndefined();
|
|
470
|
-
expect(maxmind.countryCode).not.toHaveBeenCalled();
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it("has no answer when the address is absent from the database", () => {
|
|
474
|
-
const maxmind: StubBackend = {
|
|
475
|
-
...stub(true, result("maxmind")),
|
|
476
|
-
countryCode: vi.fn<(ip: string) => string | undefined>(() => undefined),
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
expect(service({ maxmind }).country(IP)).toBeUndefined();
|
|
480
|
-
});
|
|
481
|
-
});
|