@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.
Files changed (52) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +12 -10
  2. package/.turbo/turbo-build$colon$tsc.log +10 -10
  3. package/.turbo/turbo-build.log +14 -11
  4. package/CHANGELOG.md +19 -0
  5. package/dist/IpInfoService.d.ts +8 -1
  6. package/dist/IpInfoService.d.ts.map +1 -1
  7. package/dist/IpInfoService.js +77 -84
  8. package/dist/IpInfoService.js.map +1 -1
  9. package/dist/_virtual/_rolldown/runtime.js +3 -0
  10. package/dist/backends/ipapi.d.ts +6 -0
  11. package/dist/backends/ipapi.d.ts.map +1 -1
  12. package/dist/backends/ipapi.js +117 -104
  13. package/dist/backends/ipapi.js.map +1 -1
  14. package/dist/backends/maxmind.d.ts +4 -0
  15. package/dist/backends/maxmind.d.ts.map +1 -1
  16. package/dist/backends/maxmind.js +126 -145
  17. package/dist/backends/maxmind.js.map +1 -1
  18. package/dist/cjs/IpInfoService.cjs +80 -86
  19. package/dist/cjs/backends/ipapi.cjs +118 -104
  20. package/dist/cjs/backends/maxmind.cjs +124 -165
  21. package/dist/cjs/index.cjs +7 -3
  22. package/dist/index.d.ts +6 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -4
  25. package/dist/index.js.map +1 -1
  26. package/dist/tests/ipInfoService.unit.test.d.ts +2 -0
  27. package/dist/tests/ipInfoService.unit.test.d.ts.map +1 -0
  28. package/dist/tests/ipInfoService.unit.test.js +244 -0
  29. package/dist/tests/ipInfoService.unit.test.js.map +1 -0
  30. package/dist/tests/ipapi.unit.test.d.ts +2 -0
  31. package/dist/tests/ipapi.unit.test.d.ts.map +1 -0
  32. package/dist/tests/ipapi.unit.test.js +337 -0
  33. package/dist/tests/ipapi.unit.test.js.map +1 -0
  34. package/dist/tests/ipinfo.test-d.d.ts +2 -0
  35. package/dist/tests/ipinfo.test-d.d.ts.map +1 -0
  36. package/dist/tests/ipinfo.test-d.js +97 -0
  37. package/dist/tests/ipinfo.test-d.js.map +1 -0
  38. package/dist/tests/maxmind.unit.test.d.ts +2 -0
  39. package/dist/tests/maxmind.unit.test.d.ts.map +1 -0
  40. package/dist/tests/maxmind.unit.test.js +391 -0
  41. package/dist/tests/maxmind.unit.test.js.map +1 -0
  42. package/package.json +10 -8
  43. package/src/IpInfoService.ts +19 -2
  44. package/src/backends/ipapi.ts +57 -10
  45. package/src/backends/maxmind.ts +22 -4
  46. package/src/index.ts +9 -1
  47. package/src/tests/ipInfoService.unit.test.ts +422 -0
  48. package/src/tests/ipapi.unit.test.ts +509 -0
  49. package/src/tests/ipinfo.test-d.ts +189 -0
  50. package/src/tests/maxmind.unit.test.ts +562 -0
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/vite.test.config.ts +18 -0
@@ -19,14 +19,60 @@ import type {
19
19
  IPInfoResult,
20
20
  } from "@prosopo/types";
21
21
 
22
- const TIMEOUT_MS = 700;
22
+ /**
23
+ * Default per-lookup budget. Short on purpose: an IP lookup sits in the request
24
+ * path, so a slow backend must not hold up the decision that depends on it.
25
+ */
26
+ export const DEFAULT_TIMEOUT_MS = 700;
27
+
28
+ /** The subset of global fetch this backend uses. Injected so tests need no network. */
29
+ export type FetchFn = (
30
+ url: string,
31
+ init: RequestInit,
32
+ ) => Promise<globalThis.Response>;
23
33
 
24
34
  export interface IpapiBackendConfig {
25
35
  baseUrl: string;
26
36
  apiKey?: string;
27
37
  logger?: Logger;
38
+ /** Overridden in tests; defaults to the global fetch. */
39
+ fetch?: FetchFn;
40
+ /** Overridden in tests and tunable in deployment; defaults to DEFAULT_TIMEOUT_MS. */
41
+ timeoutMs?: number;
28
42
  }
29
43
 
44
+ /**
45
+ * Parse an upstream abuser score, which arrives as a string like "0.0012 (Low)".
46
+ *
47
+ * The field is declared required by the response type but is not guaranteed by
48
+ * the wire: it comes from `response.json()`, which is cast, not validated. A
49
+ * missing value used to throw, and the throw was caught far above as a generic
50
+ * "Network or parsing error" — discarding an otherwise complete and successful
51
+ * lookup over one absent score.
52
+ *
53
+ * Returns `undefined` — not 0 — when the field is absent or unparseable. The
54
+ * scale is 0..1 with 0 meaning "clean" (see `abuserScoreThreshold`, declared
55
+ * `{min: 0, max: 1}`), so a literal 0 would assert cleanliness we have not
56
+ * established. `undefined` says "unknown" instead, and the one consumer
57
+ * (checkTrafficFilter) already resolves it with `?? 0`, so the effective
58
+ * blocking behaviour is unchanged while the distinction stays available.
59
+ *
60
+ * Fail-closed alternatives were rejected: 1 turns any upstream formatting
61
+ * change into a silent max-severity block indistinguishable from a genuine
62
+ * 1.0, and throwing reintroduces exactly the bug above — a cosmetic sub-field
63
+ * collapsing a good lookup into a total failure.
64
+ */
65
+ export const parseAbuserScore = (
66
+ score: string | undefined,
67
+ ): number | undefined => {
68
+ const head = score?.split(" ")[0];
69
+ if (!head) {
70
+ return undefined;
71
+ }
72
+ const parsed = Number.parseFloat(head);
73
+ return Number.isNaN(parsed) ? undefined : parsed;
74
+ };
75
+
30
76
  export class IpapiBackend {
31
77
  private config: IpapiBackendConfig;
32
78
 
@@ -38,6 +84,10 @@ export class IpapiBackend {
38
84
  return Boolean(this.config.baseUrl);
39
85
  }
40
86
 
87
+ private get timeoutMs(): number {
88
+ return this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
89
+ }
90
+
41
91
  async lookup(ip: string): Promise<IPInfoResponse> {
42
92
  try {
43
93
  if (!ip || typeof ip !== "string") {
@@ -54,10 +104,11 @@ export class IpapiBackend {
54
104
  }
55
105
 
56
106
  const controller = new AbortController();
57
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
107
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
58
108
 
59
109
  try {
60
- const response = await fetch(this.config.baseUrl, {
110
+ const doFetch: FetchFn = this.config.fetch ?? globalThis.fetch;
111
+ const response = await doFetch(this.config.baseUrl, {
61
112
  method: "POST",
62
113
  headers: {
63
114
  "Content-Type": "application/json",
@@ -116,12 +167,8 @@ export class IpapiBackend {
116
167
  vpnService: data.vpn?.service,
117
168
  vpnType: data.vpn?.type,
118
169
 
119
- abuserScore: Number.parseFloat(
120
- data.asn?.abuser_score.split(" ")[0] || "0",
121
- ),
122
- companyAbuserScore: Number.parseFloat(
123
- data.company?.abuser_score.split(" ")[0] || "0",
124
- ),
170
+ abuserScore: parseAbuserScore(data.asn?.abuser_score),
171
+ companyAbuserScore: parseAbuserScore(data.company?.abuser_score),
125
172
  };
126
173
 
127
174
  return result;
@@ -131,7 +178,7 @@ export class IpapiBackend {
131
178
  if (fetchError instanceof Error && fetchError.name === "AbortError") {
132
179
  return {
133
180
  isValid: false,
134
- error: `Request timed out after ${TIMEOUT_MS}ms`,
181
+ error: `Request timed out after ${this.timeoutMs}ms`,
135
182
  ip,
136
183
  };
137
184
  }
@@ -16,10 +16,28 @@ import type { Asn, City, ReaderModel } from "@maxmind/geoip2-node";
16
16
  import type { Logger } from "@prosopo/logger";
17
17
  import type { IPInfoResponse, IPInfoResult } from "@prosopo/types";
18
18
 
19
+ /**
20
+ * Opens a MaxMind database file. Injected so tests can exercise initialisation
21
+ * and lookup without shipping a .mmdb fixture, and so a failure to open one can
22
+ * be simulated at all — the real Reader only fails on a genuinely bad file.
23
+ */
24
+ export type OpenReader = (dbPath: string) => Promise<ReaderModel>;
25
+
26
+ const openReaderFromFile: OpenReader = async (
27
+ dbPath: string,
28
+ ): Promise<ReaderModel> => {
29
+ // Imported lazily: the module pulls in native-ish decoding machinery that a
30
+ // deployment without MaxMind databases should never pay for.
31
+ const { Reader } = await import("@maxmind/geoip2-node");
32
+ return Reader.open(dbPath);
33
+ };
34
+
19
35
  export interface MaxMindBackendConfig {
20
36
  cityDbPath?: string;
21
37
  asnDbPath?: string;
22
38
  logger?: Logger;
39
+ /** Overridden in tests; defaults to opening the file from disk. */
40
+ openReader?: OpenReader;
23
41
  }
24
42
 
25
43
  export class MaxMindBackend {
@@ -32,11 +50,11 @@ export class MaxMindBackend {
32
50
  }
33
51
 
34
52
  async initialize(): Promise<void> {
35
- const { Reader } = await import("@maxmind/geoip2-node");
53
+ const openReader: OpenReader = this.config.openReader ?? openReaderFromFile;
36
54
 
37
55
  if (this.config.cityDbPath) {
38
56
  try {
39
- this.cityReader = await Reader.open(this.config.cityDbPath);
57
+ this.cityReader = await openReader(this.config.cityDbPath);
40
58
  this.config.logger?.info(() => ({
41
59
  msg: "MaxMind City reader initialized",
42
60
  data: { dbPath: this.config.cityDbPath },
@@ -52,7 +70,7 @@ export class MaxMindBackend {
52
70
 
53
71
  if (this.config.asnDbPath) {
54
72
  try {
55
- this.asnReader = await Reader.open(this.config.asnDbPath);
73
+ this.asnReader = await openReader(this.config.asnDbPath);
56
74
  this.config.logger?.info(() => ({
57
75
  msg: "MaxMind ASN reader initialized",
58
76
  data: { dbPath: this.config.asnDbPath },
@@ -167,7 +185,7 @@ export class MaxMindBackend {
167
185
  }
168
186
  }
169
187
 
170
- type MaxMindUserType =
188
+ export type MaxMindUserType =
171
189
  | "business"
172
190
  | "cafe"
173
191
  | "cellular"
package/src/index.ts CHANGED
@@ -12,5 +12,13 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- export { IpInfoService } from "./IpInfoService.js";
15
+ export { IpInfoService, isNonRoutable } from "./IpInfoService.js";
16
+ export type { IpInfoBackends } from "./IpInfoService.js";
17
+ // The backends and their injection seams are part of the surface: without them
18
+ // a consumer cannot substitute either backend, and callers that already know an
19
+ // address is private cannot skip the lookup.
20
+ export { IpapiBackend } from "./backends/ipapi.js";
21
+ export type { FetchFn, IpapiBackendConfig } from "./backends/ipapi.js";
22
+ export { MaxMindBackend } from "./backends/maxmind.js";
23
+ export type { MaxMindBackendConfig, OpenReader } from "./backends/maxmind.js";
16
24
  export type { IIpInfoService, IpInfoServiceConfig } from "./types.js";
@@ -0,0 +1,422 @@
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
+ }
59
+
60
+ const stub = (
61
+ available: boolean,
62
+ answer: IPInfoResponse = result("stub"),
63
+ ): StubBackend => ({
64
+ initialize: vi.fn<() => Promise<void>>(async () => {}),
65
+ isAvailable: vi.fn<() => boolean>(() => available),
66
+ lookup: vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () => answer),
67
+ });
68
+
69
+ /**
70
+ * The stubs implement the whole interface the service uses, but not the private
71
+ * fields of the concrete classes, so the injection point needs a cast. Confined
72
+ * to this one helper rather than repeated at every call site.
73
+ */
74
+ const service = (
75
+ backends: { maxmind?: StubBackend; ipapi?: StubBackend },
76
+ config: IpInfoServiceConfig = {},
77
+ ): IpInfoService =>
78
+ new IpInfoService(config, {
79
+ maxmind: backends.maxmind as unknown as MaxMindBackend,
80
+ ipapi: backends.ipapi as unknown as IpapiBackend,
81
+ } satisfies IpInfoBackends);
82
+
83
+ describe("isNonRoutable", () => {
84
+ it("rejects the IPv4 loopback and private ranges", () => {
85
+ for (const ip of [
86
+ "127.0.0.1",
87
+ "127.255.255.255",
88
+ "10.0.0.1",
89
+ "192.168.1.1",
90
+ "169.254.1.1",
91
+ "0.0.0.0",
92
+ ]) {
93
+ expect(isNonRoutable(ip), ip).toBe(true);
94
+ }
95
+ });
96
+
97
+ it("rejects the whole 172.16/12 block and nothing either side of it", () => {
98
+ // The boundaries matter: 172.15 and 172.32 are public address space, and
99
+ // treating them as private would silently blind the service to them.
100
+ expect(isNonRoutable("172.16.0.0")).toBe(true);
101
+ expect(isNonRoutable("172.31.255.255")).toBe(true);
102
+ expect(isNonRoutable("172.15.0.1")).toBe(false);
103
+ expect(isNonRoutable("172.32.0.1")).toBe(false);
104
+ expect(isNonRoutable("172.217.16.1")).toBe(false);
105
+ });
106
+
107
+ it("treats a malformed 172. address as routable rather than crashing", () => {
108
+ // parseInt of a missing or non-numeric octet yields NaN, and every
109
+ // comparison against NaN is false — so this must fall through, not throw.
110
+ expect(isNonRoutable("172.")).toBe(false);
111
+ expect(isNonRoutable("172.abc.0.1")).toBe(false);
112
+ });
113
+
114
+ it("sees through the IPv4-mapped IPv6 prefix", () => {
115
+ // A request arriving on a dual-stack socket presents 127.0.0.1 in this
116
+ // form; missing it would send loopback traffic to a paid upstream.
117
+ expect(isNonRoutable("::ffff:127.0.0.1")).toBe(true);
118
+ expect(isNonRoutable("::FFFF:192.168.0.1")).toBe(true);
119
+ expect(isNonRoutable("::ffff:8.8.8.8")).toBe(false);
120
+ });
121
+
122
+ it("rejects the IPv6 loopback, unspecified, ULA and link-local ranges", () => {
123
+ for (const ip of [
124
+ "::1",
125
+ "::",
126
+ "fc00::1",
127
+ "fd12:3456::1",
128
+ "FD00::1",
129
+ "fe80::1",
130
+ "feb0::1",
131
+ "FE80::1",
132
+ ]) {
133
+ expect(isNonRoutable(ip), ip).toBe(true);
134
+ }
135
+ });
136
+
137
+ it("accepts public IPv4 and IPv6 addresses", () => {
138
+ for (const ip of [
139
+ "8.8.8.8",
140
+ "1.1.1.1",
141
+ "2001:4860:4860::8888",
142
+ "fec0::1",
143
+ ]) {
144
+ expect(isNonRoutable(ip), ip).toBe(false);
145
+ }
146
+ });
147
+
148
+ it("treats an empty string as routable", () => {
149
+ // Not this function's job to validate: the backends reject it, and
150
+ // claiming "non-routable" would hide a caller bug behind a plausible
151
+ // looking answer.
152
+ expect(isNonRoutable("")).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("IpInfoService construction", () => {
157
+ it("builds no backends when nothing is configured", () => {
158
+ expect(new IpInfoService({}).isAvailable()).toBe(false);
159
+ });
160
+
161
+ it("builds MaxMind from either database path alone", async () => {
162
+ // Only one of the two databases being licensed is a normal deployment.
163
+ const city = new IpInfoService({ maxmindCityDbPath: "/city.mmdb" });
164
+ const asn = new IpInfoService({ maxmindAsnDbPath: "/asn.mmdb" });
165
+
166
+ // Unopened, so still unavailable — but constructed, which the lookup
167
+ // error message distinguishes.
168
+ expect(await city.lookup(IP)).toEqual(
169
+ failure("No IP info backend available"),
170
+ );
171
+ expect(await asn.lookup(IP)).toEqual(
172
+ failure("No IP info backend available"),
173
+ );
174
+ });
175
+
176
+ it("builds ipapi when a url is configured", () => {
177
+ expect(
178
+ new IpInfoService({ ipapiUrl: "https://api.ipapi.is" }).isAvailable(),
179
+ ).toBe(true);
180
+ });
181
+
182
+ it("does not build ipapi from a key with no url", () => {
183
+ // A key without an endpoint is a half-finished configuration; guessing a
184
+ // default endpoint would send credentials somewhere never asked for.
185
+ expect(new IpInfoService({ ipapiKey: "secret" }).isAvailable()).toBe(false);
186
+ });
187
+
188
+ it("uses injected backends in place of configured ones", () => {
189
+ // Injection must win outright: a config that would otherwise build a real
190
+ // ipapi backend must not leave a second one behind.
191
+ const ipapi = stub(false);
192
+ const svc = service({ ipapi }, { ipapiUrl: "https://api.ipapi.is" });
193
+
194
+ expect(svc.isAvailable()).toBe(false);
195
+ expect(ipapi.isAvailable).toHaveBeenCalled();
196
+ });
197
+ });
198
+
199
+ describe("IpInfoService.initialize", () => {
200
+ it("initializes MaxMind and leaves ipapi alone", async () => {
201
+ // ipapi is stateless — there is nothing to open — so an initialize call
202
+ // against it would be dead work on every boot.
203
+ const maxmind = stub(true);
204
+ const ipapi = stub(true);
205
+
206
+ await service({ maxmind, ipapi }).initialize();
207
+
208
+ expect(maxmind.initialize).toHaveBeenCalledTimes(1);
209
+ expect(ipapi.initialize).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("succeeds when there are no backends at all", async () => {
213
+ await expect(new IpInfoService({}).initialize()).resolves.toBeUndefined();
214
+ });
215
+
216
+ it("logs the availability of both backends", async () => {
217
+ const info = vi.fn();
218
+ await service(
219
+ { maxmind: stub(true), ipapi: stub(false) },
220
+ { logger: { info } as never },
221
+ ).initialize();
222
+
223
+ expect(info).toHaveBeenCalledTimes(1);
224
+ const entry = info.mock.calls[0]?.[0];
225
+ expect(typeof entry).toBe("function");
226
+ expect(entry()).toMatchObject({
227
+ data: { maxmindAvailable: true, ipapiAvailable: false },
228
+ });
229
+ });
230
+
231
+ it("propagates a MaxMind initialize failure", async () => {
232
+ // MaxMind swallows its own open failures, so anything reaching here is
233
+ // unexpected and must surface at boot rather than as a silent lookup gap.
234
+ const maxmind = stub(true);
235
+ maxmind.initialize = vi.fn<() => Promise<void>>(async () => {
236
+ throw new Error("disk on fire");
237
+ });
238
+
239
+ await expect(service({ maxmind }).initialize()).rejects.toThrow(
240
+ "disk on fire",
241
+ );
242
+ });
243
+ });
244
+
245
+ describe("IpInfoService.isAvailable", () => {
246
+ it.each([
247
+ [true, true, true],
248
+ [true, false, true],
249
+ [false, true, true],
250
+ [false, false, false],
251
+ ])(
252
+ "maxmind=%s ipapi=%s -> %s",
253
+ (maxmindUp: boolean, ipapiUp: boolean, expected: boolean) => {
254
+ expect(
255
+ service({
256
+ maxmind: stub(maxmindUp),
257
+ ipapi: stub(ipapiUp),
258
+ }).isAvailable(),
259
+ ).toBe(expected);
260
+ },
261
+ );
262
+ });
263
+
264
+ describe("IpInfoService.lookup", () => {
265
+ let maxmind: StubBackend;
266
+ let ipapi: StubBackend;
267
+
268
+ beforeEach(() => {
269
+ maxmind = stub(true, result("maxmind"));
270
+ ipapi = stub(true, result("ipapi"));
271
+ });
272
+
273
+ it("short-circuits a non-routable IP without touching a backend", async () => {
274
+ // The important half of this assertion is the second: a loopback lookup
275
+ // must not spend a paid ipapi credit.
276
+ await expect(
277
+ service({ maxmind, ipapi }).lookup("127.0.0.1"),
278
+ ).resolves.toEqual({
279
+ isValid: false,
280
+ error: "Non-routable IP address",
281
+ ip: "127.0.0.1",
282
+ });
283
+ expect(ipapi.lookup).not.toHaveBeenCalled();
284
+ expect(maxmind.lookup).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it("prefers ipapi when both are available", async () => {
288
+ // ipapi carries the threat data MaxMind's free databases do not.
289
+ const response = await service({ maxmind, ipapi }).lookup(IP);
290
+
291
+ expect(response).toEqual(result("ipapi"));
292
+ expect(maxmind.lookup).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it("falls back to MaxMind when ipapi returns an invalid result", async () => {
296
+ ipapi = stub(true, failure("upstream 503"));
297
+
298
+ await expect(service({ maxmind, ipapi }).lookup(IP)).resolves.toEqual(
299
+ result("maxmind"),
300
+ );
301
+ });
302
+
303
+ it("returns the ipapi error when MaxMind cannot cover for it", async () => {
304
+ // Reporting the real upstream error beats a generic "no backend" message
305
+ // that would send someone looking at the wrong system.
306
+ ipapi = stub(true, failure("upstream 503"));
307
+
308
+ await expect(
309
+ service({ maxmind: stub(false), ipapi }).lookup(IP),
310
+ ).resolves.toEqual(failure("upstream 503"));
311
+ });
312
+
313
+ it("returns the ipapi error when MaxMind is not configured at all", async () => {
314
+ ipapi = stub(true, failure("upstream 503"));
315
+
316
+ await expect(service({ ipapi }).lookup(IP)).resolves.toEqual(
317
+ failure("upstream 503"),
318
+ );
319
+ });
320
+
321
+ it("logs the fallback with the upstream error attached", async () => {
322
+ const debug = vi.fn();
323
+ ipapi = stub(true, failure("upstream 503"));
324
+
325
+ await service({ maxmind, ipapi }, { logger: { debug } as never }).lookup(
326
+ IP,
327
+ );
328
+
329
+ expect(debug).toHaveBeenCalledTimes(1);
330
+ expect(debug.mock.calls[0]?.[0]()).toMatchObject({
331
+ data: { ip: IP, error: "upstream 503" },
332
+ });
333
+ });
334
+
335
+ it("logs 'unknown' when the failed result carries no error field", async () => {
336
+ // The result type permits it; the log line must stay well-formed rather
337
+ // than printing undefined.
338
+ const debug = vi.fn();
339
+ ipapi = stub(true, { isValid: false, ip: IP } as IPInfoResponse);
340
+
341
+ await service({ maxmind, ipapi }, { logger: { debug } as never }).lookup(
342
+ IP,
343
+ );
344
+
345
+ expect(debug.mock.calls[0]?.[0]()).toMatchObject({
346
+ data: { error: "unknown" },
347
+ });
348
+ });
349
+
350
+ it("does not log a fallback when ipapi succeeds", async () => {
351
+ const debug = vi.fn();
352
+
353
+ await service({ maxmind, ipapi }, { logger: { debug } as never }).lookup(
354
+ IP,
355
+ );
356
+
357
+ expect(debug).not.toHaveBeenCalled();
358
+ });
359
+
360
+ it("uses MaxMind alone when ipapi is unavailable", async () => {
361
+ // Unavailable is checked before the call, so no failed request is made.
362
+ const down = stub(false);
363
+ const response = await service({ maxmind, ipapi: down }).lookup(IP);
364
+
365
+ expect(response).toEqual(result("maxmind"));
366
+ expect(down.lookup).not.toHaveBeenCalled();
367
+ });
368
+
369
+ it("reports that no backend is available when neither is", async () => {
370
+ await expect(
371
+ service({ maxmind: stub(false), ipapi: stub(false) }).lookup(IP),
372
+ ).resolves.toEqual(failure("No IP info backend available"));
373
+ });
374
+
375
+ it("reports that no backend is available when none is configured", async () => {
376
+ await expect(new IpInfoService({}).lookup(IP)).resolves.toEqual(
377
+ failure("No IP info backend available"),
378
+ );
379
+ });
380
+
381
+ it("passes the IP through to the backend unmodified", async () => {
382
+ // Normalisation happens inside isNonRoutable only; the backend must see
383
+ // exactly what the caller supplied.
384
+ await service({ ipapi }).lookup("2001:4860:4860::8888");
385
+
386
+ expect(ipapi.lookup).toHaveBeenCalledWith("2001:4860:4860::8888");
387
+ });
388
+
389
+ it("does not catch a backend that throws", async () => {
390
+ // Deliberate: both backends convert their own failures into invalid
391
+ // results, so a throw reaching here is a bug and must not be disguised as
392
+ // a routine lookup miss.
393
+ ipapi.lookup = vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () => {
394
+ throw new Error("unexpected");
395
+ });
396
+
397
+ await expect(service({ maxmind, ipapi }).lookup(IP)).rejects.toThrow(
398
+ "unexpected",
399
+ );
400
+ expect(maxmind.lookup).not.toHaveBeenCalled();
401
+ });
402
+
403
+ it("re-checks availability on every lookup", async () => {
404
+ // A backend can come up between calls; the answer must not be cached at
405
+ // construction time.
406
+ let up = false;
407
+ const flaky: StubBackend = {
408
+ initialize: vi.fn<() => Promise<void>>(async () => {}),
409
+ isAvailable: vi.fn<() => boolean>(() => up),
410
+ lookup: vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () =>
411
+ result("recovered"),
412
+ ),
413
+ };
414
+ const svc = service({ ipapi: flaky });
415
+
416
+ expect(await svc.lookup(IP)).toEqual(
417
+ failure("No IP info backend available"),
418
+ );
419
+ up = true;
420
+ expect(await svc.lookup(IP)).toEqual(result("recovered"));
421
+ });
422
+ });