From 9d14ed1a6e9689be1dd6effad48b3f86744125eb Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Fri, 30 Oct 2026 23:24:07 +0500 Subject: [PATCH] Update formula errors, bulk results, caching and key documentation --- .github/workflows/test.yml | 22 ++++ Code.gs | 148 +++++++++++----------- README.md | 14 +-- test/Code.test.cjs | 247 +++++++++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 test/Code.test.cjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d585ae7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: ['22', '24'] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: ${{ matrix.node }} + - run: node --test test/Code.test.cjs diff --git a/Code.gs b/Code.gs index 0713123..036bc70 100644 --- a/Code.gs +++ b/Code.gs @@ -26,7 +26,7 @@ function onOpen() { } /** - * Run this once to save the user's IPGeolocation.io API key. + * Saves the IPGeolocation.io API key used by this spreadsheet. */ function setIpGeolocationApiKey() { const ui = SpreadsheetApp.getUi(); @@ -149,10 +149,11 @@ function IPSECURITY(ip, field) { */ function IPGEO_BULK(ipRange, field, include) { try { - const ips = normalizeRangeToList_(ipRange); + const inputs = normalizeRangeToList_(ipRange); + const ips = inputs.filter(function (ip) { return ip !== ''; }); if (!ips.length) { - return [['']]; + return inputs.length ? inputs.map(function () { return ['']; }) : [['']]; } if (ips.length > IPGEO_CONFIG.MAX_BULK_SIZE) { @@ -161,8 +162,13 @@ function IPGEO_BULK(ipRange, field, include) { const results = fetchIpGeoBulk_(ips, include); - return results.map(function (item) { - return [getNestedValue_(item, field)]; + if (!Array.isArray(results) || results.length !== ips.length) { + throw new Error('Invalid bulk response: expected one result per IP address.'); + } + + let resultIndex = 0; + return inputs.map(function (ip) { + return [ip === '' ? '' : getNestedValue_(results[resultIndex++], field)]; }); } catch (error) { return [[formatError_(error)]]; @@ -223,76 +229,68 @@ function fetchIpSecurity_(ip) { } function fetchIpGeoBulk_(ips, include) { - const apiKey = getApiKey_(); - - const params = { apiKey: apiKey, output: 'json' }; + const params = {}; if (include && String(include).trim()) { params.include = String(include).trim(); } - const url = `${IPGEO_CONFIG.BASE_URL}/ipgeo-bulk?${buildQueryString_(params)}`; - const cacheKey = makeCacheKey_('POST:' + url + ':' + JSON.stringify(ips)); - const cached = CacheService.getScriptCache().get(cacheKey); - - if (cached) { - return JSON.parse(cached); - } - - const response = UrlFetchApp.fetch(url, { + return fetchJsonWithCache_(buildUrl_('/ipgeo-bulk', params), { method: 'post', contentType: 'application/json', payload: JSON.stringify({ ips: ips }), muteHttpExceptions: true }); +} - const statusCode = response.getResponseCode(); - const body = response.getContentText(); +function fetchJsonWithCache_(url, options) { + options = options || { method: 'get', muteHttpExceptions: true }; + const cacheKey = makeCacheKey_(options.method.toUpperCase() + ':' + url + ':' + (options.payload || '')); + const cached = getCachedJson_(cacheKey); - if (statusCode < 200 || statusCode >= 300) { - throw new Error(`IPGeolocation.io API error ${statusCode}: ${body}`); + if (cached !== null) { + return cached; } - const data = JSON.parse(body); + const requestKey = decodeURIComponent((url.match(/[?&]apiKey=([^&]*)/i) || [])[1] || ''); + let data; + try { + const response = UrlFetchApp.fetch(url, options); + const statusCode = response.getResponseCode(); + const body = response.getContentText(); - CacheService.getScriptCache().put( - cacheKey, - JSON.stringify(data), - IPGEO_CONFIG.CACHE_TTL_SECONDS - ); + if (statusCode < 200 || statusCode >= 300) { + throw new Error(`IPGeolocation.io API error ${statusCode}: ${body}`); + } + data = JSON.parse(body); + } catch (error) { + throw new Error(redactErrorMessage_(error, requestKey)); + } + + cacheJson_(cacheKey, data); return data; } -function fetchJsonWithCache_(url) { - const cacheKey = makeCacheKey_('GET:' + url); - const cached = CacheService.getScriptCache().get(cacheKey); - - if (cached) { - return JSON.parse(cached); +function getCachedJson_(cacheKey) { + try { + const cached = CacheService.getScriptCache().get(cacheKey); + return cached ? JSON.parse(cached) : null; + } catch (error) { + return null; } +} - const response = UrlFetchApp.fetch(url, { - method: 'get', - muteHttpExceptions: true - }); - - const statusCode = response.getResponseCode(); - const body = response.getContentText(); - - if (statusCode < 200 || statusCode >= 300) { - throw new Error(`IPGeolocation.io API error ${statusCode}: ${body}`); +function cacheJson_(cacheKey, data) { + try { + const json = JSON.stringify(data); + if (json.length > 100000 || Utilities.newBlob(json).getBytes().length > 100000) { + return; + } + CacheService.getScriptCache().put(cacheKey, json, IPGEO_CONFIG.CACHE_TTL_SECONDS); + } catch (error) { + return; } - - const data = JSON.parse(body); - - CacheService.getScriptCache().put( - cacheKey, - JSON.stringify(data), - IPGEO_CONFIG.CACHE_TTL_SECONDS - ); - - return data; } function buildUrl_(path, params) { @@ -364,26 +362,13 @@ function getNestedValue_(object, path) { } function normalizeRangeToList_(range) { - if (!Array.isArray(range)) { - return String(range || '').trim() ? [String(range).trim()] : []; - } - const list = []; - - range.forEach(function (row) { - if (Array.isArray(row)) { - row.forEach(function (cell) { - const value = String(cell || '').trim(); - if (value) { - list.push(value); - } - }); - } else { - const value = String(row || '').trim(); - if (value) { - list.push(value); - } - } + const rows = Array.isArray(range) ? range : [range]; + rows.forEach(function (row) { + const cells = Array.isArray(row) ? row : [row]; + cells.forEach(function (cell) { + list.push(String(cell || '').trim()); + }); }); return list; @@ -404,5 +389,22 @@ function makeCacheKey_(value) { } function formatError_(error) { - return 'Error: ' + error.message; + try { + const apiKey = PropertiesService.getScriptProperties().getProperty(IPGEO_CONFIG.API_KEY_PROPERTY); + return 'Error: ' + redactErrorMessage_(error, apiKey); + } catch (storageError) { + return 'Error: IPGeolocation.io request failed.'; + } +} + +function redactErrorMessage_(error, apiKey) { + let message = error && error.message !== undefined ? String(error.message) : String(error || 'Request failed.'); + if (apiKey) { + const variants = [apiKey, encodeURIComponent(apiKey), JSON.stringify(apiKey).slice(1, -1)]; + variants.sort(function (a, b) { return b.length - a.length; }).forEach(function (value) { + const pattern = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + message = message.replace(new RegExp(pattern, 'gi'), '[REDACTED]'); + }); + } + return message.replace(/([?&]apiKey=)[^&#\s"'<>]*/gi, '$1[REDACTED]'); } diff --git a/README.md b/README.md index 755e066..99a757b 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ The first time you run a function, Google will ask you to review and approve per After permission is granted, a small popup will appear asking for your API key. Paste your IPGeolocation.io API key and click **OK**. -Your key is now saved securely inside the script settings. You never need to put it inside a formula or a cell. To learn more about why this matters, see [Important Notes About API Keys](#important-notes-about-api-keys). +The sheet’s API key is now saved in its Apps Script project. All formulas in this sheet use this key. See [Important Notes About API Keys](#important-notes-about-api-keys) for details about sharing and editor access. ### Refresh Your Google Sheet @@ -347,7 +347,7 @@ These shortcut formulas save typing for the most common lookups. They are equiva ### Bulk Lookup Formula -When you have a large list of IP addresses, use `IPGEO_BULK` instead of individual formulas. It processes up to 50,000 IP addresses in a single call and fills a column of results automatically. This is much faster and puts less strain on your sheet than dragging individual formulas down hundreds of rows. +`IPGEO_BULK` sends the nonblank IP addresses in a range in one request and returns results in the same order. Blank cells stay blank, so results remain aligned with the input. The request limit is 50,000 IP addresses; Google Apps Script execution limits also apply. ```excel =IPGEO_BULK(A2:A100, "location.country_name") @@ -393,11 +393,11 @@ For the best onboarding experience, consider using the tab structure described i ## Important Notes About API Keys -Every user should use their own IPGeolocation.io API key. +This spreadsheet uses one IPGeolocation.io API key for all of its formulas. -Never paste a real API key directly inside a formula or a cell. The script stores the key using Google Apps Script's built-in properties storage, which keeps it out of your spreadsheet entirely and makes the sheet safe to share. +Save it through the IPGeolocation.io menu rather than entering it in a formula or cell. Anyone with edit access can access the attached Apps Script project and read or change its saved key. -If a team member needs to use the same sheet, they should follow the [Save Your API Key](#save-your-api-key) step using their own key. If you need to remove a saved key at any time, use the Clear API Key option in the IPGeolocation.io menu. +For a separate API key, use a separate copy of the sheet and set the key in that copy. The Clear API Key menu option removes the key used by this sheet. --- @@ -491,8 +491,8 @@ This integration targets the IPGeolocation.io v3 API. The three endpoints used a `/v3/security` powers the [Dedicated Security Formula](#dedicated-security-formula). -Results are cached for six hours using Google Apps Script's built-in cache service. This reduces API credit usage when the same IP address is looked up multiple times across formulas in the same sheet. +Responses up to 100 KB may be cached for up to six hours using Google Apps Script's built-in cache service. Missing or invalid cached data triggers a fresh request. If caching fails, successful lookups still return their results. -The API key is stored using `PropertiesService`, which is Google's secure key-value store for script settings. External HTTP requests are made using `UrlFetchApp`. +The sheet’s API key is stored in script properties shared by the script’s users. External HTTP requests are made using `UrlFetchApp`. For the latest API field references, visit the [IPGeolocation.io API documentation](https://ipgeolocation.io/documentation.html). diff --git a/test/Code.test.cjs b/test/Code.test.cjs new file mode 100644 index 0000000..1bf5152 --- /dev/null +++ b/test/Code.test.cjs @@ -0,0 +1,247 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const source = fs.readFileSync( + process.env.IPGEO_TEST_SOURCE || path.join(__dirname, '..', 'Code.gs'), + 'utf8' +); + +function loadScript(settings = {}) { + const key = settings.key === undefined ? 'test-key-123' : settings.key; + const properties = new Map(key ? [['IPGEOLOCATION_API_KEY', key]] : []); + const entries = new Map(); + const requests = []; + const writes = []; + const ui = { + Button: { OK: 'OK' }, + ButtonSet: { OK_CANCEL: 'OK_CANCEL' }, + prompt: () => ({ + getSelectedButton: () => settings.button || 'OK', + getResponseText: () => settings.prompt === undefined ? ' new-key ' : settings.prompt + }), + alert: () => {} + }; + const cache = { + get: key => { + if (settings.readError) throw new Error('Cache unavailable'); + if (settings.cached !== undefined) return settings.cached; + return entries.get(key) || null; + }, + put: (key, value, ttl) => { + if (settings.writeError) throw new Error('Cache unavailable'); + assert.ok(Buffer.byteLength(value, 'utf8') <= 100000); + writes.push({ key, value, ttl }); + entries.set(key, value); + } + }; + const context = vm.createContext({ + PropertiesService: { + getScriptProperties: () => ({ + getProperty: key => properties.get(key) || null, + setProperty: (key, value) => properties.set(key, value), + deleteProperty: key => properties.delete(key) + }) + }, + SpreadsheetApp: { getUi: () => ui }, + CacheService: { + getScriptCache: () => { + if (settings.cacheError) throw new Error('Cache unavailable'); + return cache; + } + }, + Utilities: { + DigestAlgorithm: { SHA_256: 'sha256' }, + computeDigest: (algorithm, value) => [...crypto.createHash(algorithm).update(value).digest()], + newBlob: value => ({ getBytes: () => [...Buffer.from(value, 'utf8')] }) + }, + UrlFetchApp: { + fetch: (url, options) => { + requests.push({ url, options: JSON.parse(JSON.stringify(options)) }); + if (settings.transportError) throw new Error(settings.transportError(url)); + const data = settings.data === undefined + ? { location: { country_name: 'United States', city: 'Mountain View' }, + asn: { organization: 'Google' }, time_zone: { name: 'America/Los_Angeles' }, + security: { is_vpn: false, threat_score: 0 } } + : settings.data; + return { + getResponseCode: () => settings.status || 200, + getContentText: () => settings.body === undefined ? JSON.stringify(data) : settings.body + }; + } + } + }); + vm.runInContext(source, context, { filename: 'Code.gs' }); + return { context, properties, requests, writes }; +} + +const plain = value => JSON.parse(JSON.stringify(value)); + +test('existing lookup formulas and shortcuts retain their values and v3 routes', () => { + const { context: c, requests } = loadScript(); + assert.equal(c.IPGEO_COUNTRY('8.8.8.8'), 'United States'); + assert.equal(c.IPGEO_CITY('8.8.8.8'), 'Mountain View'); + assert.equal(c.IPGEO_TIMEZONE('8.8.8.8'), 'America/Los_Angeles'); + assert.equal(c.IPGEO_ASN_ORG('8.8.8.8'), 'Google'); + assert.equal(c.IPGEO_IS_VPN('8.8.8.8'), false); + assert.equal(c.IPGEO_THREAT_SCORE('8.8.8.8'), 0); + assert.equal(JSON.parse(c.IPGEO_JSON('8.8.8.8')).location.city, 'Mountain View'); + assert.equal(c.IPGEO('8.8.8.8', 'missing'), ''); + assert.equal(requests.length, 3); + assert.deepEqual(requests.map(r => new URL(r.url).pathname), ['/v3/ipgeo', '/v3/ipgeo', '/v3/security']); + assert.equal(new URL(requests[1].url).searchParams.get('include'), 'security'); + assert.equal(requests[0].options.method, 'get'); + assert.equal(requests[0].options.muteHttpExceptions, true); +}); + +test('single lookups keep blank-IP behavior and encode domains and include values', () => { + const { context: c, requests } = loadScript(); + c.IPGEO('', 'location.city'); + c.IPGEO(' example.com ', 'location.city', 'security,abuse'); + assert.equal(new URL(requests[0].url).searchParams.has('ip'), false); + assert.equal(new URL(requests[1].url).searchParams.get('ip'), 'example.com'); + assert.equal(new URL(requests[1].url).searchParams.get('include'), 'security,abuse'); +}); + +test('bulk results retain leading, middle and trailing blank positions', () => { + const { context: c, requests } = loadScript({ data: [ + { security: { is_vpn: false } }, { security: { is_vpn: true } } + ] }); + const result = c.IPGEO_BULK([[''], ['8.8.8.8'], [' '], ['1.1.1.1'], ['']], 'security.is_vpn', 'security'); + assert.deepEqual(plain(result), [[''], [false], [''], [true], ['']]); + assert.deepEqual(JSON.parse(requests[0].options.payload), { ips: ['8.8.8.8', '1.1.1.1'] }); + assert.equal(requests[0].options.method, 'post'); + assert.equal(requests[0].options.contentType, 'application/json'); + assert.equal(new URL(requests[0].url).pathname, '/v3/ipgeo-bulk'); +}); + +test('bulk blank ranges make no request and retain their output height', () => { + const { context: c, requests } = loadScript(); + assert.deepEqual(plain(c.IPGEO_BULK([[''], [' '], ['']], 'location.city')), [[''], [''], ['']]); + assert.deepEqual(plain(c.IPGEO_BULK([], 'location.city')), [['']]); + assert.deepEqual(plain(c.IPGEO_BULK('', 'location.city')), [['']]); + assert.equal(requests.length, 0); +}); + +test('bulk scalar, flat and rectangular ranges keep row-major order and duplicates', () => { + const data = [{ location: { city: 'A' } }, { location: { city: 'B' } }]; + assert.deepEqual(plain(loadScript({ data: [data[0]] }).context.IPGEO_BULK('8.8.8.8', 'location.city')), [['A']]); + assert.deepEqual(plain(loadScript({ data }).context.IPGEO_BULK(['8.8.8.8', '', '8.8.8.8'], 'location.city')), [['A'], [''], ['B']]); + assert.deepEqual(plain(loadScript({ data }).context.IPGEO_BULK([['8.8.8.8', ''], ['', '1.1.1.1']], 'location.city')), [['A'], [''], [''], ['B']]); +}); + +test('bulk validates the nonblank request count and response length', () => { + const { context: c, requests } = loadScript({ data: [] }); + assert.match(c.IPGEO_BULK(Array(50001).fill('8.8.8.8'), 'location.city')[0][0], /up to 50000/); + assert.equal(requests.length, 0); + assert.match(c.IPGEO_BULK(['8.8.8.8'], 'location.city')[0][0], /bulk response/i); + assert.match(loadScript({ data: {} }).context.IPGEO_BULK(['8.8.8.8'], 'location.city')[0][0], /bulk response/i); +}); + +test('bulk accepts 50000 nonblank inputs while retaining extra blank positions', () => { + const ips = Array(50000).fill('8.8.8.8'); + const { context: c, requests } = loadScript({ data: ips.map(() => ({ city: 'A' })) }); + const values = c.IPGEO_BULK(['', ...ips, ''], 'city'); + assert.equal(values.length, 50002); + assert.equal(values[0][0], ''); + assert.equal(values[1][0], 'A'); + assert.equal(values[50000][0], 'A'); + assert.equal(values[50001][0], ''); + assert.equal(JSON.parse(requests[0].options.payload).ips.length, 50000); +}); + +test('per-entry API errors do not shift later bulk results', () => { + const { context: c } = loadScript({ data: [ + { city: 'A' }, { message: 'Invalid IP address' }, { city: 'B' } + ] }); + assert.deepEqual(plain(c.IPGEO_BULK(['8.8.8.8', 'invalid', '', '1.1.1.1'], 'city')), [['A'], [''], [''], ['B']]); +}); + +for (const setting of ['readError', 'writeError', 'cacheError']) { + test(`${setting} does not discard successful GET or POST results`, () => { + const single = loadScript({ [setting]: true }); + assert.equal(single.context.IPGEO_COUNTRY('8.8.8.8'), 'United States'); + const bulk = loadScript({ [setting]: true, data: [{ location: { city: 'A' } }] }); + assert.deepEqual(plain(bulk.context.IPGEO_BULK(['8.8.8.8'], 'location.city')), [['A']]); + }); +} + +test('invalid cached JSON triggers a fresh GET or POST', () => { + assert.equal(loadScript({ cached: '{broken' }).context.IPGEO_COUNTRY('8.8.8.8'), 'United States'); + const bulk = loadScript({ cached: '{broken', data: [{ location: { city: 'A' } }] }); + assert.deepEqual(plain(bulk.context.IPGEO_BULK(['8.8.8.8'], 'location.city')), [['A']]); + assert.equal(bulk.requests.length, 1); +}); + +test('large ASCII and multibyte responses return without exceeding the cache limit', () => { + for (const text of ['x'.repeat(100001), '界'.repeat(40000)]) { + const single = loadScript({ data: { location: { city: text } } }); + assert.equal(single.context.IPGEO_CITY('8.8.8.8'), text); + assert.equal(single.writes.length, 0); + const bulk = loadScript({ data: [{ location: { city: text } }] }); + assert.deepEqual(plain(bulk.context.IPGEO_BULK(['8.8.8.8'], 'location.city')), [[text]]); + assert.equal(bulk.writes.length, 0); + } +}); + +test('cache hits retain TTL and are separated by method, payload and API key', () => { + const { context: c, properties, requests, writes } = loadScript({ data: [{ city: 'A' }] }); + c.IPGEO_BULK(['8.8.8.8'], 'city'); + c.IPGEO_BULK(['8.8.8.8'], 'city'); + c.IPGEO_BULK(['1.1.1.1'], 'city'); + properties.set('IPGEOLOCATION_API_KEY', 'other-test-key'); + c.IPGEO_BULK(['8.8.8.8'], 'city'); + c.IPGEO('8.8.8.8', 'city'); + assert.equal(requests.length, 4); + assert.equal(new Set(writes.map(w => w.key)).size, 4); + assert.ok(writes.every(w => w.ttl === 21600 && !w.key.includes('test-key'))); +}); + +for (const method of ['IPGEO', 'IPGEO_JSON', 'IPSECURITY', 'IPGEO_BULK']) { + test(`${method} redacts credentials from response and transport errors`, () => { + const key = 'test-key+a/b?c=%'; + for (const transport of [false, true]) { + const echoed = `apiKey=${encodeURIComponent(key)} raw=${key} lower=${encodeURIComponent(key).toLowerCase()}`; + const { context: c } = loadScript({ key, status: 429, body: echoed, + transportError: transport ? url => `Request failed for ${url}; ${echoed}` : null }); + const result = String(c[method](method === 'IPGEO_BULK' ? ['8.8.8.8'] : '8.8.8.8', 'location.city')); + assert.ok(!result.includes(key), result); + assert.ok(!result.toLowerCase().includes(encodeURIComponent(key).toLowerCase()), result); + assert.match(result, /redacted/i); + if (!transport) assert.match(result, /429/); + } + }); +} + +test('a changed saved key does not expose the key used by an in-flight request', () => { + let app; + app = loadScript({ transportError: () => { + app.properties.set('IPGEOLOCATION_API_KEY', 'replacement-key'); + return 'Failed request using test-key-123'; + } }); + assert.ok(!app.context.IPGEO_COUNTRY('8.8.8.8').includes('test-key-123')); +}); + +test('HTTP status, malformed JSON and missing-key errors remain useful', () => { + assert.match(loadScript({ status: 403, body: '{"message":"Access denied"}' }).context.IPGEO_COUNTRY('8.8.8.8'), /403.*Access denied/); + assert.match(loadScript({ body: 'unavailable' }).context.IPGEO_COUNTRY('8.8.8.8'), /^Error:/); + const missing = loadScript({ key: '' }); + assert.match(missing.context.IPGEO_COUNTRY('8.8.8.8'), /Missing API key/); + assert.equal(missing.requests.length, 0); +}); + +test('menu setters and clearing retain one shared script key', () => { + const { context: c, properties } = loadScript(); + c.setIpGeolocationApiKey(); + assert.equal(properties.get('IPGEOLOCATION_API_KEY'), 'new-key'); + c.clearIpGeolocationApiKey(); + assert.equal(properties.has('IPGEOLOCATION_API_KEY'), false); + for (const settings of [{ button: 'CANCEL' }, { prompt: ' ' }]) { + const app = loadScript(settings); + app.context.setIpGeolocationApiKey(); + assert.equal(app.properties.get('IPGEOLOCATION_API_KEY'), 'test-key-123'); + } +});