Files
MyIP/tests/transform-ip-data.test.js
T
jason5ng32andClaude Opus 5.5 cf3bb69d8b Feat(ipinfo): show how the quality score was reached and rename the proxy field
Follows the v3 score output of MyIP-api (this frontend no longer reads
the v2 shape; deploy it right after the API).

- A toggle next to the quality score opens a panel: a short explanation
  assembled from the score dimensions (network type, anonymity with its
  last-observed date, reputation, the ASN's neighbourhood, the verdict),
  and all boolean attributes as check / cross / question-mark icons.
  Residential proxies seen only in the API's own sighting history read
  as "suspected".
- The card's "Proxy" field becomes "Anonymizer": Tor exit, iCloud
  Private Relay, residential proxy, proxy, VPN (each with a suspected
  variant when only one source agrees) or not detected. Card fields are
  anonymity / anonymityCode / anonymityProvider / anonymityProtocol; the
  provider line hides when the API returns null. Texts live under
  ipInfos.anonymity.*, with one code-to-key map for card and report.
- Diagnostic reports store the anonymity code (REPORT_VERSION 2; v1
  links show the unsupported-version page, reworded to fit either
  direction). The persona collector no longer sends the unused isProxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:19:14 +08:00

249 lines
11 KiB
JavaScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
transformDataFromIPapi,
extractAdvancedData,
} from '../frontend/utils/transform-ip-data.js';
// simple i18n translation stub: return key with prefix for assertions, to verify template references rather than hardcoded translation text
const t = (key) => `<${key}>`;
// v3 tags with nothing flagged.
const cleanTags = {
isBogon: false, isMobile: false, isResidential: false, isSatellite: false,
isCrawler: false, isBenignBot: false, isDatacenter: false, isTor: false,
isProxy: false, isVPN: false, isAnyAnonymizer: false, isRelay: false,
isResidentialProxy: false, isAbuser: false, isNative: true,
};
const basicRaw = {
country_name: 'Japan',
country: 'JP',
region: 'Tokyo',
city: 'Shinjuku',
latitude: '35.6938',
longitude: '139.7034',
org: 'Example Telecom',
asn: 'AS12345',
};
describe('transformDataFromIPapi()', () => {
it('throws when raw payload carries an error', () => {
assert.throws(
() => transformDataFromIPapi({ error: true, reason: 'nope' }, 1, t, 'en'),
/nope/,
);
});
it('maps base fields verbatim for non-source-0 callers', () => {
const out = transformDataFromIPapi(basicRaw, 1, t, 'en');
assert.equal(out.country_name, 'Japan');
assert.equal(out.country_code, 'JP');
assert.equal(out.region, 'Tokyo');
assert.equal(out.city, 'Shinjuku');
assert.equal(out.isp, 'Example Telecom');
assert.equal(out.asn, 'AS12345');
});
it('builds bgp.tools asnlink for AS-prefixed ASN', () => {
const out = transformDataFromIPapi(basicRaw, 1, t, 'en');
assert.equal(out.asnlink, 'https://bgp.tools/as/AS12345');
});
it('returns asnlink=false when ASN is not AS-prefixed', () => {
const out = transformDataFromIPapi({ ...basicRaw, asn: '12345' }, 1, t, 'en');
assert.equal(out.asnlink, false);
});
it('returns asnlink=false when ASN is missing', () => {
const { asn, ...noAsn } = basicRaw;
const out = transformDataFromIPapi(noAsn, 1, t, 'en');
assert.equal(out.asnlink, false);
assert.equal(out.asn, '');
});
it('omits map URLs when lat/lon missing', () => {
const { latitude, longitude, ...noCoord } = basicRaw;
const out = transformDataFromIPapi(noCoord, 1, t, 'en');
assert.equal(out.mapUrl, '');
assert.equal(out.mapUrl_dark, '');
});
it('builds map URLs with language + dark variant when lat/lon present', () => {
const out = transformDataFromIPapi(basicRaw, 1, t, 'zh');
// Map request coords are quantized to 1 decimal for CF edge-cache sharing,
// while the displayed lat/lon keep full precision.
assert.match(out.mapUrl, /^\/api\/map\?latitude=35\.7&longitude=139\.7&language=zh$/);
assert.match(out.mapUrl_dark, /CanvasMode=Dark$/);
assert.equal(out.latitude, '35.6938');
assert.equal(out.longitude, '139.7034');
});
it("treats country='N/A' as empty country_code", () => {
const out = transformDataFromIPapi({ ...basicRaw, country: 'N/A' }, 1, t, 'en');
assert.equal(out.country_code, '');
});
it('localizes country_name from the code, ignoring the upstream string', () => {
// upstream said 'Japan' but the UI language is zh → CLDR name wins
const out = transformDataFromIPapi(basicRaw, 1, t, 'zh');
assert.equal(out.country_name, '日本');
});
it('falls back to the upstream country_name when the code is unusable', () => {
const na = transformDataFromIPapi({ ...basicRaw, country: 'N/A' }, 1, t, 'zh');
assert.equal(na.country_name, 'Japan');
const none = transformDataFromIPapi({ ...basicRaw, country: undefined, country_name: undefined }, 1, t, 'zh');
assert.equal(none.country_name, '');
});
it('merges advancedData when ipGeoSource === 0', () => {
const withAdvanced = {
...basicRaw,
advancedData: {
tags: { ...cleanTags },
operatorType: 'Business',
score: 92,
anonymityProtocol: 'http',
anonymityProvider: 'Acme',
dimensions: { anonymity: { class: null, sources: null, firstSeen: null, lastSeen: null } },
},
};
const out = transformDataFromIPapi(withAdvanced, 0, t, 'en');
assert.equal(out.anonymity, '<ipInfos.anonymity.none>');
assert.equal(out.anonymityCode, 'none');
assert.equal(out.type, '<ipInfos.advancedData.type.Business>');
assert.equal(out.qualityScore, 92);
assert.equal(out.anonymityProtocol, 'http');
assert.equal(out.anonymityProvider, 'Acme');
assert.equal(out.isNativeIP, true);
});
});
describe('extractAdvancedData()', () => {
it('propagates sign_in_required sentinel verbatim for type / qualityScore / isNativeIP / anonymity', () => {
const out = extractAdvancedData({
tags: 'sign_in_required',
score: 'sign_in_required',
operatorType: 'sign_in_required',
anonymityProvider: 'sign_in_required',
anonymityProtocol: 'sign_in_required',
}, t);
assert.equal(out.anonymity, 'sign_in_required');
assert.equal(out.type, 'sign_in_required');
assert.equal(out.qualityScore, 'sign_in_required');
assert.equal(out.isNativeIP, 'sign_in_required');
// Sentinels never pose as a service name.
assert.equal(out.anonymityProvider, null);
assert.equal(out.anonymityProtocol, null);
});
it('propagates quota_exceeded sentinel verbatim, and drops the report codes', () => {
const out = extractAdvancedData({
tags: 'quota_exceeded',
score: 'quota_exceeded',
operatorType: 'quota_exceeded',
anonymityProtocol: 'quota_exceeded',
}, t);
assert.equal(out.anonymity, 'quota_exceeded');
assert.equal(out.type, 'quota_exceeded');
assert.equal(out.qualityScore, 'quota_exceeded');
assert.equal(out.isNativeIP, 'quota_exceeded');
// Locale-free report codes must be absent for gated data.
assert.equal(out.anonymityCode, undefined);
assert.equal(out.ipTypeCode, undefined);
});
it('keeps provider / protocol null when the API named no service', () => {
const out = extractAdvancedData({
tags: { ...cleanTags }, operatorType: 'Residential', score: 80,
anonymityProvider: null, anonymityProtocol: null,
}, t);
assert.equal(out.anonymityProvider, null);
assert.equal(out.anonymityProtocol, null);
const missing = extractAdvancedData({ tags: { ...cleanTags }, operatorType: 'Residential', score: 80 }, t);
assert.equal(missing.anonymityProvider, null);
});
describe('anonymity verdict (display text + report code agree)', () => {
const verdict = (tags, anonymity) => {
const out = extractAdvancedData({
tags, operatorType: 'Hosting', score: 50, anonymityProtocol: 'socks5',
...(anonymity !== undefined ? { dimensions: { anonymity } } : {}),
}, t);
return [out.anonymity, out.anonymityCode];
};
const key = (name) => `<ipInfos.anonymity.${name}>`;
const anon = (cls, sources = 'single') => ({ class: cls, sources, firstSeen: null, lastSeen: null });
it('Tor first, then iCloud Private Relay', () => {
assert.deepEqual(verdict({ ...cleanTags, isAnyAnonymizer: true, isTor: true, isProxy: true }, anon('tor', 'multi')),
[key('tor'), 'tor']);
assert.deepEqual(verdict({ ...cleanTags, isAnyAnonymizer: true, isRelay: true }, anon('relay')),
[key('relay'), 'relay']);
});
it('confirmed residential proxy', () => {
const tags = { ...cleanTags, isProxy: true, isAnyAnonymizer: true, isResidentialProxy: true };
assert.deepEqual(verdict(tags, anon('residential_proxy', 'multi')), [key('residential'), 'residential']);
});
it('proxy class: multi → proxy, otherwise possible proxy', () => {
const tags = { ...cleanTags, isProxy: true, isAnyAnonymizer: true };
assert.deepEqual(verdict(tags, anon('proxy', 'multi')), [key('proxy'), 'proxy']);
assert.deepEqual(verdict(tags, anon('proxy', 'single')), [key('proxySuspected'), 'proxy_suspected']);
assert.deepEqual(verdict(tags, anon('proxy', null)), [key('proxySuspected'), 'proxy_suspected']);
});
it('vpn class: multi → VPN, otherwise possible VPN (isProxy may be true too)', () => {
const tags = { ...cleanTags, isVPN: true, isProxy: true, isAnyAnonymizer: true };
assert.deepEqual(verdict(tags, anon('vpn', 'multi')), [key('vpn'), 'vpn']);
assert.deepEqual(verdict(tags, anon('vpn', 'single')), [key('vpnSuspected'), 'vpn_suspected']);
});
it('residential proxy only our sighting history reports → suspected', () => {
assert.deepEqual(verdict({ ...cleanTags, isResidentialProxy: true }, anon('residential_proxy')),
[key('suspectedResidential'), 'suspected_residential']);
});
it('nothing detected → none (also without dimensions)', () => {
assert.deepEqual(verdict(cleanTags, { class: null, sources: null }), [key('none'), 'none']);
assert.deepEqual(verdict(cleanTags), [key('none'), 'none']);
});
it('every code has a label in the en pack and is a report code', async () => {
const { ANONYMITY_I18N_KEYS } = await import('../frontend/utils/transform-ip-data.js');
const { ANONYMITY_CODES } = await import('../common/report-schema.js');
const fs = await import('node:fs');
const en = JSON.parse(fs.readFileSync(new URL('../frontend/locales/en.json', import.meta.url), 'utf8'));
const resolve = (path) => path.split('.').reduce((node, part) => node?.[part], en);
assert.deepEqual(Object.keys(ANONYMITY_I18N_KEYS).sort(), [...ANONYMITY_CODES].sort());
for (const code of ANONYMITY_CODES) {
assert.equal(typeof resolve(ANONYMITY_I18N_KEYS[code]), 'string', code);
}
assert.equal(typeof resolve('ipInfos.anonymity.label'), 'string');
});
});
it('passes tags / dimensions / scoreVersion through for the score-details panel', () => {
const dimensions = {
reputation: { level: 'none', lastSeen: null },
anonymity: { class: null, sources: null, firstSeen: null, lastSeen: null },
network: { level: 'low', asn: 18144 },
};
const tags = { ...cleanTags };
const out = extractAdvancedData({ tags, operatorType: 'Business', score: 90, scoreVersion: 3, dimensions }, t);
assert.equal(out.scoreTags, tags);
assert.equal(out.scoreDimensions, dimensions);
assert.equal(out.scoreVersion, 3);
});
it('leaves the score-details fields undefined when gated', () => {
const gated = extractAdvancedData({ tags: 'quota_exceeded', score: 'quota_exceeded', operatorType: 'quota_exceeded' }, t);
assert.equal(gated.scoreTags, undefined);
assert.equal(gated.scoreDimensions, undefined);
assert.equal(gated.scoreVersion, undefined);
});
});