@prosopo/ipinfo 0.2.40 → 0.3.0
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 +12 -10
- package/.turbo/turbo-build$colon$tsc.log +10 -10
- package/.turbo/turbo-build.log +14 -11
- package/CHANGELOG.md +19 -0
- package/dist/IpInfoService.d.ts +8 -1
- package/dist/IpInfoService.d.ts.map +1 -1
- package/dist/IpInfoService.js +77 -84
- package/dist/IpInfoService.js.map +1 -1
- package/dist/_virtual/_rolldown/runtime.js +3 -0
- package/dist/backends/ipapi.d.ts +6 -0
- package/dist/backends/ipapi.d.ts.map +1 -1
- package/dist/backends/ipapi.js +117 -104
- package/dist/backends/ipapi.js.map +1 -1
- package/dist/backends/maxmind.d.ts +4 -0
- package/dist/backends/maxmind.d.ts.map +1 -1
- package/dist/backends/maxmind.js +126 -145
- package/dist/backends/maxmind.js.map +1 -1
- package/dist/cjs/IpInfoService.cjs +80 -86
- package/dist/cjs/backends/ipapi.cjs +118 -104
- package/dist/cjs/backends/maxmind.cjs +124 -165
- package/dist/cjs/index.cjs +7 -3
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/tests/ipInfoService.unit.test.d.ts +2 -0
- package/dist/tests/ipInfoService.unit.test.d.ts.map +1 -0
- package/dist/tests/ipInfoService.unit.test.js +244 -0
- package/dist/tests/ipInfoService.unit.test.js.map +1 -0
- package/dist/tests/ipapi.unit.test.d.ts +2 -0
- package/dist/tests/ipapi.unit.test.d.ts.map +1 -0
- package/dist/tests/ipapi.unit.test.js +337 -0
- package/dist/tests/ipapi.unit.test.js.map +1 -0
- package/dist/tests/ipinfo.test-d.d.ts +2 -0
- package/dist/tests/ipinfo.test-d.d.ts.map +1 -0
- package/dist/tests/ipinfo.test-d.js +97 -0
- package/dist/tests/ipinfo.test-d.js.map +1 -0
- package/dist/tests/maxmind.unit.test.d.ts +2 -0
- package/dist/tests/maxmind.unit.test.d.ts.map +1 -0
- package/dist/tests/maxmind.unit.test.js +391 -0
- package/dist/tests/maxmind.unit.test.js.map +1 -0
- package/package.json +10 -8
- package/src/IpInfoService.ts +19 -2
- package/src/backends/ipapi.ts +57 -10
- package/src/backends/maxmind.ts +22 -4
- package/src/index.ts +9 -1
- package/src/tests/ipInfoService.unit.test.ts +422 -0
- package/src/tests/ipapi.unit.test.ts +509 -0
- package/src/tests/ipinfo.test-d.ts +189 -0
- package/src/tests/maxmind.unit.test.ts +562 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +18 -0
|
@@ -0,0 +1,562 @@
|
|
|
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
|
+
// Testing strategy: the reader is the seam. Shipping a real .mmdb fixture would
|
|
16
|
+
// make these tests depend on MaxMind's data staying constant, and would leave
|
|
17
|
+
// the failure paths — a missing database, a corrupt one, an IP absent from it —
|
|
18
|
+
// impossible to reach at all. The reader is therefore injected, and everything
|
|
19
|
+
// the backend does with its output runs for real.
|
|
20
|
+
|
|
21
|
+
import type { Asn, City, ReaderModel } from "@maxmind/geoip2-node";
|
|
22
|
+
import type { IPInfoResult } from "@prosopo/types";
|
|
23
|
+
import { describe, expect, it, vi } from "vitest";
|
|
24
|
+
import {
|
|
25
|
+
MaxMindBackend,
|
|
26
|
+
type MaxMindUserType,
|
|
27
|
+
type OpenReader,
|
|
28
|
+
} from "../backends/maxmind.js";
|
|
29
|
+
|
|
30
|
+
const IP = "8.8.8.8";
|
|
31
|
+
const CITY_DB = "/dbs/GeoLite2-City.mmdb";
|
|
32
|
+
const ASN_DB = "/dbs/GeoLite2-ASN.mmdb";
|
|
33
|
+
|
|
34
|
+
/** Builds a reader that answers city/asn lookups however the test needs. */
|
|
35
|
+
const reader = (behaviour: {
|
|
36
|
+
city?: () => City;
|
|
37
|
+
asn?: () => Asn;
|
|
38
|
+
}): ReaderModel => {
|
|
39
|
+
const notSupported = (): never => {
|
|
40
|
+
// The real Reader throws when asked for a database it did not open.
|
|
41
|
+
throw new Error("database does not support this lookup");
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
city: behaviour.city ?? notSupported,
|
|
45
|
+
asn: behaviour.asn ?? notSupported,
|
|
46
|
+
} as unknown as ReaderModel;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const cityData = (overrides: Partial<City> = {}): City =>
|
|
50
|
+
({
|
|
51
|
+
country: { isoCode: "US", names: { en: "United States" } },
|
|
52
|
+
city: { names: { en: "Mountain View" } },
|
|
53
|
+
subdivisions: [{ names: { en: "California" } }],
|
|
54
|
+
location: {
|
|
55
|
+
latitude: 37.4,
|
|
56
|
+
longitude: -122.1,
|
|
57
|
+
timeZone: "America/Los_Angeles",
|
|
58
|
+
},
|
|
59
|
+
traits: {},
|
|
60
|
+
...overrides,
|
|
61
|
+
}) as City;
|
|
62
|
+
|
|
63
|
+
const asnData = (overrides: Partial<Asn> = {}): Asn =>
|
|
64
|
+
({
|
|
65
|
+
autonomousSystemNumber: 15169,
|
|
66
|
+
autonomousSystemOrganization: "Google LLC",
|
|
67
|
+
...overrides,
|
|
68
|
+
}) as Asn;
|
|
69
|
+
|
|
70
|
+
/** An opener that returns the given reader for whichever path it is asked for. */
|
|
71
|
+
const opens = (readers: {
|
|
72
|
+
city?: ReaderModel;
|
|
73
|
+
asn?: ReaderModel;
|
|
74
|
+
}): OpenReader => {
|
|
75
|
+
return async (dbPath: string): Promise<ReaderModel> => {
|
|
76
|
+
const model = dbPath === CITY_DB ? readers.city : readers.asn;
|
|
77
|
+
if (undefined === model) {
|
|
78
|
+
throw new Error(`no database at ${dbPath}`);
|
|
79
|
+
}
|
|
80
|
+
return model;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const expectValid = (
|
|
85
|
+
response: Awaited<ReturnType<MaxMindBackend["lookup"]>>,
|
|
86
|
+
): IPInfoResult => {
|
|
87
|
+
if (!response.isValid) {
|
|
88
|
+
throw new Error(`expected a valid result, got: ${response.error}`);
|
|
89
|
+
}
|
|
90
|
+
return response;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describe("MaxMindBackend.initialize", () => {
|
|
94
|
+
it("opens only the databases that are configured", async () => {
|
|
95
|
+
const openReader = vi.fn<OpenReader>(
|
|
96
|
+
opens({ city: reader({ city: () => cityData() }) }),
|
|
97
|
+
);
|
|
98
|
+
const backend = new MaxMindBackend({ cityDbPath: CITY_DB, openReader });
|
|
99
|
+
|
|
100
|
+
await backend.initialize();
|
|
101
|
+
|
|
102
|
+
expect(openReader).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(openReader).toHaveBeenCalledWith(CITY_DB);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("opens neither database when no paths are configured", async () => {
|
|
107
|
+
const openReader = vi.fn<OpenReader>(opens({}));
|
|
108
|
+
await new MaxMindBackend({ openReader }).initialize();
|
|
109
|
+
|
|
110
|
+
expect(openReader).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("logs the readers it did open", async () => {
|
|
114
|
+
const info = vi.fn();
|
|
115
|
+
await new MaxMindBackend({
|
|
116
|
+
cityDbPath: CITY_DB,
|
|
117
|
+
asnDbPath: ASN_DB,
|
|
118
|
+
openReader: opens({
|
|
119
|
+
city: reader({ city: () => cityData() }),
|
|
120
|
+
asn: reader({ asn: () => asnData() }),
|
|
121
|
+
}),
|
|
122
|
+
logger: { info, warn: vi.fn(), debug: vi.fn() } as never,
|
|
123
|
+
}).initialize();
|
|
124
|
+
|
|
125
|
+
const messages = info.mock.calls.map(
|
|
126
|
+
(call: unknown[]): string => (call[0] as () => { msg: string })().msg,
|
|
127
|
+
);
|
|
128
|
+
expect(messages).toEqual([
|
|
129
|
+
"MaxMind City reader initialized",
|
|
130
|
+
"MaxMind ASN reader initialized",
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps the ASN database when the City database fails to open", async () => {
|
|
135
|
+
// Deployments routinely ship one and not the other. A failure to open one
|
|
136
|
+
// must not take the other down with it.
|
|
137
|
+
const backend = new MaxMindBackend({
|
|
138
|
+
cityDbPath: CITY_DB,
|
|
139
|
+
asnDbPath: ASN_DB,
|
|
140
|
+
openReader: opens({ asn: reader({ asn: () => asnData() }) }),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await backend.initialize();
|
|
144
|
+
|
|
145
|
+
expect(backend.isAvailable()).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not throw when every database fails to open", async () => {
|
|
149
|
+
// A missing .mmdb is a deployment mistake, but it must degrade to an
|
|
150
|
+
// unavailable backend rather than crash the process at startup.
|
|
151
|
+
const backend = new MaxMindBackend({
|
|
152
|
+
cityDbPath: CITY_DB,
|
|
153
|
+
asnDbPath: ASN_DB,
|
|
154
|
+
openReader: opens({}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await expect(backend.initialize()).resolves.toBeUndefined();
|
|
158
|
+
expect(backend.isAvailable()).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("logs a warning for each database it could not open", async () => {
|
|
162
|
+
const warn = vi.fn();
|
|
163
|
+
const backend = new MaxMindBackend({
|
|
164
|
+
cityDbPath: CITY_DB,
|
|
165
|
+
asnDbPath: ASN_DB,
|
|
166
|
+
openReader: opens({}),
|
|
167
|
+
logger: { warn, info: vi.fn(), debug: vi.fn() } as never,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await backend.initialize();
|
|
171
|
+
|
|
172
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
173
|
+
// The payloads are built lazily, so evaluate them: a broken builder would
|
|
174
|
+
// otherwise only surface while someone is debugging a missing database.
|
|
175
|
+
const messages = warn.mock.calls.map(
|
|
176
|
+
(call: unknown[]): string => (call[0] as () => { msg: string })().msg,
|
|
177
|
+
);
|
|
178
|
+
expect(messages).toEqual([
|
|
179
|
+
"Failed to initialize MaxMind City reader",
|
|
180
|
+
"Failed to initialize MaxMind ASN reader",
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("is safe to call twice", async () => {
|
|
185
|
+
// Callers may initialize defensively; the second call must not leave the
|
|
186
|
+
// backend worse off than the first.
|
|
187
|
+
const backend = new MaxMindBackend({
|
|
188
|
+
cityDbPath: CITY_DB,
|
|
189
|
+
openReader: opens({ city: reader({ city: () => cityData() }) }),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await backend.initialize();
|
|
193
|
+
await backend.initialize();
|
|
194
|
+
|
|
195
|
+
expect(backend.isAvailable()).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("MaxMindBackend.isAvailable", () => {
|
|
200
|
+
it("is unavailable before initialize, even when paths are configured", async () => {
|
|
201
|
+
// Configured is not the same as ready: the databases are opened from disk
|
|
202
|
+
// asynchronously, so a caller that skips initialize gets nothing.
|
|
203
|
+
const backend = new MaxMindBackend({
|
|
204
|
+
cityDbPath: CITY_DB,
|
|
205
|
+
openReader: opens({ city: reader({ city: () => cityData() }) }),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(backend.isAvailable()).toBe(false);
|
|
209
|
+
await backend.initialize();
|
|
210
|
+
expect(backend.isAvailable()).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("MaxMindBackend.lookup", () => {
|
|
215
|
+
const initialised = async (readers: {
|
|
216
|
+
city?: ReaderModel;
|
|
217
|
+
asn?: ReaderModel;
|
|
218
|
+
}): Promise<MaxMindBackend> => {
|
|
219
|
+
const backend = new MaxMindBackend({
|
|
220
|
+
cityDbPath: readers.city ? CITY_DB : undefined,
|
|
221
|
+
asnDbPath: readers.asn ? ASN_DB : undefined,
|
|
222
|
+
openReader: opens(readers),
|
|
223
|
+
});
|
|
224
|
+
await backend.initialize();
|
|
225
|
+
return backend;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
it("refuses to look up before the databases are open", async () => {
|
|
229
|
+
const backend = new MaxMindBackend({ cityDbPath: CITY_DB });
|
|
230
|
+
|
|
231
|
+
await expect(backend.lookup(IP)).resolves.toEqual({
|
|
232
|
+
isValid: false,
|
|
233
|
+
error: "MaxMind readers not initialized",
|
|
234
|
+
ip: IP,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("maps city data onto the shared result shape", async () => {
|
|
239
|
+
const backend = await initialised({
|
|
240
|
+
city: reader({ city: () => cityData() }),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = expectValid(await backend.lookup(IP));
|
|
244
|
+
|
|
245
|
+
expect(result).toMatchObject({
|
|
246
|
+
ip: IP,
|
|
247
|
+
isValid: true,
|
|
248
|
+
country: "United States",
|
|
249
|
+
countryCode: "US",
|
|
250
|
+
region: "California",
|
|
251
|
+
city: "Mountain View",
|
|
252
|
+
latitude: 37.4,
|
|
253
|
+
longitude: -122.1,
|
|
254
|
+
timezone: "America/Los_Angeles",
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("reports threat indicators the free databases never populate as false", async () => {
|
|
259
|
+
// GeoLite2 does not carry these traits. They must read false rather than
|
|
260
|
+
// undefined, or every consumer has to re-decide what absence means.
|
|
261
|
+
const backend = await initialised({
|
|
262
|
+
city: reader({ city: () => cityData() }),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = expectValid(await backend.lookup(IP));
|
|
266
|
+
|
|
267
|
+
expect(result.isVPN).toBe(false);
|
|
268
|
+
expect(result.isTor).toBe(false);
|
|
269
|
+
expect(result.isProxy).toBe(false);
|
|
270
|
+
expect(result.isAbuser).toBe(false);
|
|
271
|
+
expect(result.isMobile).toBe(false);
|
|
272
|
+
expect(result.isCrawler).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("reads the anonymity traits when a paid database supplies them", async () => {
|
|
276
|
+
const backend = await initialised({
|
|
277
|
+
city: reader({
|
|
278
|
+
city: () =>
|
|
279
|
+
cityData({
|
|
280
|
+
traits: {
|
|
281
|
+
isAnonymousVpn: true,
|
|
282
|
+
isTorExitNode: true,
|
|
283
|
+
isHostingProvider: true,
|
|
284
|
+
isSatelliteProvider: true,
|
|
285
|
+
},
|
|
286
|
+
} as Partial<City>),
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const result = expectValid(await backend.lookup(IP));
|
|
291
|
+
|
|
292
|
+
expect(result.isVPN).toBe(true);
|
|
293
|
+
expect(result.isTor).toBe(true);
|
|
294
|
+
expect(result.isDatacenter).toBe(true);
|
|
295
|
+
expect(result.isSatellite).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("treats a residential proxy as a proxy", async () => {
|
|
299
|
+
// Two distinct traits collapse into one flag; missing either would let a
|
|
300
|
+
// proxied request through unflagged.
|
|
301
|
+
const residential = await initialised({
|
|
302
|
+
city: reader({
|
|
303
|
+
city: () =>
|
|
304
|
+
cityData({ traits: { isResidentialProxy: true } } as Partial<City>),
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
const publicProxy = await initialised({
|
|
308
|
+
city: reader({
|
|
309
|
+
city: () =>
|
|
310
|
+
cityData({ traits: { isPublicProxy: true } } as Partial<City>),
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(expectValid(await residential.lookup(IP)).isProxy).toBe(true);
|
|
315
|
+
expect(expectValid(await publicProxy.lookup(IP)).isProxy).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("falls back to the ASN database for the AS number", async () => {
|
|
319
|
+
const backend = await initialised({
|
|
320
|
+
city: reader({ city: () => cityData() }),
|
|
321
|
+
asn: reader({ asn: () => asnData() }),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const result = expectValid(await backend.lookup(IP));
|
|
325
|
+
|
|
326
|
+
expect(result.asnNumber).toBe(15169);
|
|
327
|
+
expect(result.asnOrganization).toBe("Google LLC");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("prefers the City database's ASN traits over the ASN database", async () => {
|
|
331
|
+
// The City database is the more specific source when it carries traits.
|
|
332
|
+
const backend = await initialised({
|
|
333
|
+
city: reader({
|
|
334
|
+
city: () =>
|
|
335
|
+
cityData({
|
|
336
|
+
traits: {
|
|
337
|
+
autonomousSystemNumber: 111,
|
|
338
|
+
autonomousSystemOrganization: "From City",
|
|
339
|
+
},
|
|
340
|
+
} as Partial<City>),
|
|
341
|
+
}),
|
|
342
|
+
asn: reader({ asn: () => asnData({ autonomousSystemNumber: 222 }) }),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const result = expectValid(await backend.lookup(IP));
|
|
346
|
+
|
|
347
|
+
expect(result.asnNumber).toBe(111);
|
|
348
|
+
expect(result.asnOrganization).toBe("From City");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("returns ASN-only data when no City database is configured", async () => {
|
|
352
|
+
const backend = await initialised({
|
|
353
|
+
asn: reader({ asn: () => asnData() }),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const result = expectValid(await backend.lookup(IP));
|
|
357
|
+
|
|
358
|
+
expect(result.asnNumber).toBe(15169);
|
|
359
|
+
expect(result.country).toBeUndefined();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports no data when the IP is in neither database", async () => {
|
|
363
|
+
// The real Reader throws AddressNotFoundError for an unknown IP. Both
|
|
364
|
+
// lookups failing is not an error — it is simply an absent answer.
|
|
365
|
+
const missing = (): never => {
|
|
366
|
+
throw new Error("address not found");
|
|
367
|
+
};
|
|
368
|
+
const backend = await initialised({
|
|
369
|
+
city: reader({ city: missing }),
|
|
370
|
+
asn: reader({ asn: missing }),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await expect(backend.lookup(IP)).resolves.toEqual({
|
|
374
|
+
isValid: false,
|
|
375
|
+
error: "No MaxMind data available for IP",
|
|
376
|
+
ip: IP,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("still answers from the ASN database when the City lookup throws", async () => {
|
|
381
|
+
// Partial data beats no data: an IP present in one database and not the
|
|
382
|
+
// other is common.
|
|
383
|
+
const backend = await initialised({
|
|
384
|
+
city: reader({
|
|
385
|
+
city: (): never => {
|
|
386
|
+
throw new Error("address not found");
|
|
387
|
+
},
|
|
388
|
+
}),
|
|
389
|
+
asn: reader({ asn: () => asnData() }),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const result = expectValid(await backend.lookup(IP));
|
|
393
|
+
|
|
394
|
+
expect(result.asnNumber).toBe(15169);
|
|
395
|
+
expect(result.country).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("maps a hosting user type onto the provider type", async () => {
|
|
399
|
+
const backend = await initialised({
|
|
400
|
+
city: reader({
|
|
401
|
+
city: () =>
|
|
402
|
+
cityData({ traits: { userType: "hosting" } } as Partial<City>),
|
|
403
|
+
}),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(expectValid(await backend.lookup(IP)).providerType).toBe("hosting");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("maps every user type MaxMind can emit", async () => {
|
|
410
|
+
// Exhaustive on purpose: each case is a routing decision downstream, and
|
|
411
|
+
// a type silently falling through to undefined is indistinguishable from
|
|
412
|
+
// "no data" at the consumer.
|
|
413
|
+
const forUserType = async (
|
|
414
|
+
userType: string,
|
|
415
|
+
): Promise<string | undefined> => {
|
|
416
|
+
const backend = await initialised({
|
|
417
|
+
city: reader({
|
|
418
|
+
city: () =>
|
|
419
|
+
cityData({ traits: { userType } } as unknown as Partial<City>),
|
|
420
|
+
}),
|
|
421
|
+
});
|
|
422
|
+
return expectValid(await backend.lookup(IP)).providerType;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Typed as a total Record over the union, so adding a user type to
|
|
426
|
+
// MaxMindUserType without deciding its mapping fails the type check
|
|
427
|
+
// rather than silently falling through to undefined at runtime.
|
|
428
|
+
const expected: Record<MaxMindUserType, string | undefined> = {
|
|
429
|
+
hosting: "hosting",
|
|
430
|
+
content_delivery_network: "hosting",
|
|
431
|
+
college: "education",
|
|
432
|
+
school: "education",
|
|
433
|
+
library: "education",
|
|
434
|
+
government: "government",
|
|
435
|
+
military: "government",
|
|
436
|
+
business: "business",
|
|
437
|
+
residential: "isp",
|
|
438
|
+
cellular: "isp",
|
|
439
|
+
dialup: "isp",
|
|
440
|
+
cafe: "isp",
|
|
441
|
+
traveler: "isp",
|
|
442
|
+
router: "isp",
|
|
443
|
+
// Deliberately unmapped: neither is a provider type the consumers
|
|
444
|
+
// route on, so both reach the `default` arm and report "no data".
|
|
445
|
+
consumer_privacy_network: undefined,
|
|
446
|
+
search_engine_spider: undefined,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
for (const [userType, providerType] of Object.entries(expected)) {
|
|
450
|
+
expect(await forUserType(userType), userType).toBe(providerType);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("logs each failed database lookup with the IP and the cause", async () => {
|
|
455
|
+
// The log lines are built lazily; if the payload builder is wrong nobody
|
|
456
|
+
// finds out until an incident, so it is evaluated here.
|
|
457
|
+
const debug = vi.fn();
|
|
458
|
+
const missing = (): never => {
|
|
459
|
+
throw new Error("address not found");
|
|
460
|
+
};
|
|
461
|
+
const backend = new MaxMindBackend({
|
|
462
|
+
cityDbPath: CITY_DB,
|
|
463
|
+
asnDbPath: ASN_DB,
|
|
464
|
+
openReader: opens({
|
|
465
|
+
city: reader({ city: missing }),
|
|
466
|
+
asn: reader({ asn: missing }),
|
|
467
|
+
}),
|
|
468
|
+
logger: { debug, warn: vi.fn(), info: vi.fn() } as never,
|
|
469
|
+
});
|
|
470
|
+
await backend.initialize();
|
|
471
|
+
|
|
472
|
+
await backend.lookup(IP);
|
|
473
|
+
|
|
474
|
+
expect(debug).toHaveBeenCalledTimes(2);
|
|
475
|
+
type DebugPayload = { msg: string; data: { ip: string } };
|
|
476
|
+
const payloads: DebugPayload[] = debug.mock.calls.map(
|
|
477
|
+
(call: unknown[]): DebugPayload => (call[0] as () => DebugPayload)(),
|
|
478
|
+
);
|
|
479
|
+
expect(payloads.map((p) => p.msg)).toEqual([
|
|
480
|
+
"MaxMind City lookup failed",
|
|
481
|
+
"MaxMind ASN lookup failed",
|
|
482
|
+
]);
|
|
483
|
+
expect(payloads.every((p) => p.data.ip === IP)).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("leaves the provider type unset for a user type it does not map", async () => {
|
|
487
|
+
// e.g. "search_engine_spider" — deliberately unmapped rather than forced
|
|
488
|
+
// into a category it does not belong to.
|
|
489
|
+
const backend = await initialised({
|
|
490
|
+
city: reader({
|
|
491
|
+
city: () =>
|
|
492
|
+
cityData({
|
|
493
|
+
traits: { userType: "search_engine_spider" },
|
|
494
|
+
} as unknown as Partial<City>),
|
|
495
|
+
}),
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
expect(expectValid(await backend.lookup(IP)).providerType).toBeUndefined();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("leaves the provider type unset when there is no user type at all", async () => {
|
|
502
|
+
const backend = await initialised({
|
|
503
|
+
city: reader({ city: () => cityData() }),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(expectValid(await backend.lookup(IP)).providerType).toBeUndefined();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("turns a failure during mapping into an error response", async () => {
|
|
510
|
+
// The per-database try blocks only cover the lookup call itself. A throw
|
|
511
|
+
// while reading the returned record — here a getter that fails, standing
|
|
512
|
+
// in for any malformed decode — happens outside them, and must still not
|
|
513
|
+
// escape to the caller.
|
|
514
|
+
const hostile = {
|
|
515
|
+
get traits(): never {
|
|
516
|
+
throw new Error("corrupt record");
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
const backend = await initialised({
|
|
520
|
+
city: reader({ city: () => hostile as unknown as City }),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await expect(backend.lookup(IP)).resolves.toEqual({
|
|
524
|
+
isValid: false,
|
|
525
|
+
error: "MaxMind lookup error: corrupt record",
|
|
526
|
+
ip: IP,
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("describes a non-Error failure without printing [object Object]", async () => {
|
|
531
|
+
const hostile = {
|
|
532
|
+
get traits(): never {
|
|
533
|
+
// The case under test is a non-Error throw, hence the string.
|
|
534
|
+
throw "just a string";
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
const backend = await initialised({
|
|
538
|
+
city: reader({ city: () => hostile as unknown as City }),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const response = await backend.lookup(IP);
|
|
542
|
+
|
|
543
|
+
expect(response).toMatchObject({
|
|
544
|
+
isValid: false,
|
|
545
|
+
error: "MaxMind lookup error: just a string",
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe("MaxMindBackend default reader", () => {
|
|
551
|
+
it("does not throw when the real database file is missing", async () => {
|
|
552
|
+
// Exercises the un-injected path: the lazy import of the MaxMind reader
|
|
553
|
+
// runs for real, and its failure to open a nonexistent file must be
|
|
554
|
+
// contained exactly like an injected one.
|
|
555
|
+
const backend = new MaxMindBackend({
|
|
556
|
+
cityDbPath: "/nonexistent/prosopo-test/GeoLite2-City.mmdb",
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
await expect(backend.initialize()).resolves.toBeUndefined();
|
|
560
|
+
expect(backend.isAvailable()).toBe(false);
|
|
561
|
+
});
|
|
562
|
+
});
|