diff --git a/docs/commands/dev.md b/docs/commands/dev.md index f418d058000..7568d4a83e2 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -22,6 +22,7 @@ netlify dev - `framework` (*string*) - framework to use. Defaults to #auto which automatically detects a framework - `functions` (*string*) - specify a functions folder to serve - `functionsPort` (*string*) - port of functions server +- `geo` (*cache | mock | update*) - force geolocation data to be updated, use cached data from the last 24h if found, or use a mock location - `live` (*boolean*) - start a public live session - `offline` (*boolean*) - disables any features that require network access - `port` (*string*) - port of netlify dev diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 196bfb481ff..1e3b23f5572 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -138,6 +138,7 @@ "husky": "^7.0.4", "ini": "^2.0.0", "mock-fs": "^5.1.2", + "nock": "^13.2.4", "p-timeout": "^4.0.0", "rewiremock": "^3.14.3", "seedrandom": "^3.0.5", @@ -14405,6 +14406,12 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, + "node_modules/lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", + "dev": true + }, "node_modules/lodash.some": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", @@ -15831,6 +15838,21 @@ "isarray": "0.0.1" } }, + "node_modules/nock": { + "version": "13.2.4", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.4.tgz", + "integrity": "sha512-8GPznwxcPNCH/h8B+XZcKjYPXnUV5clOKCjAqyjsiqA++MpNx9E9+t8YPp0MbThO+KauRo7aZJ1WuIZmOrT2Ug==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash.set": "^4.3.2", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -18235,6 +18257,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -33416,6 +33447,12 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, + "lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", + "dev": true + }, "lodash.some": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", @@ -34486,6 +34523,18 @@ } } }, + "nock": { + "version": "13.2.4", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.4.tgz", + "integrity": "sha512-8GPznwxcPNCH/h8B+XZcKjYPXnUV5clOKCjAqyjsiqA++MpNx9E9+t8YPp0MbThO+KauRo7aZJ1WuIZmOrT2Ug==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash.set": "^4.3.2", + "propagate": "^2.0.0" + } + }, "node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -36300,6 +36349,12 @@ } } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index e8107bab45a..bb304d1a4da 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "Sam Holmes (https://samholmes.net)", "Sander de Groot (https://degroot.dev)", "Sarah Drasner (https://twitter.com/sarah_edo)", + "Sarah Etter (http://www.sarahetter.com)", "Scott Spence (https://twitter.com/spences10)", "Sean Grove (https://twitter.com/sgrove)", "Sebastian Smolorz", @@ -330,6 +331,7 @@ "husky": "^7.0.4", "ini": "^2.0.0", "mock-fs": "^5.1.2", + "nock": "^13.2.4", "p-timeout": "^4.0.0", "rewiremock": "^3.14.3", "seedrandom": "^3.0.5", diff --git a/src/commands/dev/dev.js b/src/commands/dev/dev.js index c5f6c290b6c..4eb5f66eb34 100644 --- a/src/commands/dev/dev.js +++ b/src/commands/dev/dev.js @@ -201,18 +201,33 @@ const FRAMEWORK_PORT_TIMEOUT = 6e5 * @param {*} params.addonsUrls * @param {import('../base-command').NetlifyOptions["config"]} params.config * @param {() => Promise} params.getUpdatedConfig + * @param {string} params.geolocationMode * @param {*} params.settings + * @param {boolean} params.offline * @param {*} params.site + * @param {import('../../utils/state-config').StateConfig} params.state * @returns */ -const startProxyServer = async ({ addonsUrls, config, getUpdatedConfig, settings, site }) => { +const startProxyServer = async ({ + addonsUrls, + config, + geolocationMode, + getUpdatedConfig, + offline, + settings, + site, + state, +}) => { const url = await startProxy({ addonsUrls, config, configPath: site.configPath, + geolocationMode, getUpdatedConfig, + offline, projectDir: site.root, settings, + state, }) if (!url) { @@ -365,7 +380,16 @@ const dev = async (options, command) => { return normalizedNewConfig } - let url = await startProxyServer({ settings, site, addonsUrls, config, getUpdatedConfig }) + let url = await startProxyServer({ + addonsUrls, + config, + geolocationMode: options.geo, + getUpdatedConfig, + offline: options.offline, + settings, + site, + state, + }) const liveTunnelUrl = await handleLiveTunnel({ options, site, api, settings }) url = liveTunnelUrl || url @@ -484,17 +508,19 @@ const createDevCommand = (program) => { .option('-o ,--offline', 'disables any features that require network access') .option('-l, --live', 'start a public live session', false) .option('--functionsPort ', 'port of functions server', (value) => Number.parseInt(value)) + .addOption( + new Option( + '--geo ', + 'force geolocation data to be updated, use cached data from the last 24h if found, or use a mock location', + ) + .choices(['cache', 'mock', 'update']) + .default('cache'), + ) .addOption( new Option('--staticServerPort ', 'port of the static app server used when no framework is detected') .argParser((value) => Number.parseInt(value)) .hideHelp(), ) - .addOption( - new Option( - '-g ,--locationDb ', - 'specify the path to a local GeoIP location database in MMDB format', - ).hideHelp(), - ) .addOption(new Option('--graph', 'enable Netlify Graph support').hideHelp()) .addExamples([ 'netlify dev', diff --git a/src/lib/edge-functions/headers.js b/src/lib/edge-functions/headers.js index b6be4e18905..13b93eac534 100644 --- a/src/lib/edge-functions/headers.js +++ b/src/lib/edge-functions/headers.js @@ -1,5 +1,6 @@ module.exports = { Functions: 'x-deno-functions', + Geo: 'x-nf-geo', PassHost: 'X-NF-Pass-Host', Passthrough: 'x-deno-pass', RequestID: 'X-NF-Request-ID', diff --git a/src/lib/edge-functions/proxy.js b/src/lib/edge-functions/proxy.js index a4d961a378c..a8f8d3bec05 100644 --- a/src/lib/edge-functions/proxy.js +++ b/src/lib/edge-functions/proxy.js @@ -6,6 +6,7 @@ const getAvailablePort = require('get-port') const { v4: generateUUID } = require('uuid') const { NETLIFYDEVERR, NETLIFYDEVWARN, chalk, log } = require('../../utils/command-helpers') +const { getGeoLocation } = require('../geo-location') const { getPathInProject } = require('../settings') const { startSpinner, stopSpinner } = require('../spinner') @@ -41,7 +42,7 @@ const handleProxyRequest = (req, proxyReq) => { }) } -const initializeProxy = async ({ config, configPath, getUpdatedConfig, settings }) => { +const initializeProxy = async ({ config, configPath, geolocationMode, getUpdatedConfig, offline, settings, state }) => { const { functions: internalFunctions, importMap, path: internalFunctionsPath } = await getInternalFunctions() const { port: mainPort } = settings const userFunctionsPath = config.build.edge_functions @@ -66,7 +67,13 @@ const initializeProxy = async ({ config, configPath, getUpdatedConfig, settings return } - const { registry } = await server + const [geoLocation, { registry }] = await Promise.all([ + getGeoLocation({ mode: geolocationMode, offline, state }), + server, + ]) + + // Setting header with geolocation. + req.headers[headers.Geo] = JSON.stringify(geoLocation) await registry.initialize() diff --git a/src/lib/geo-location.js b/src/lib/geo-location.js new file mode 100644 index 00000000000..4d2cc25bc81 --- /dev/null +++ b/src/lib/geo-location.js @@ -0,0 +1,99 @@ +// @ts-check +const fetch = require('node-fetch') + +const API_URL = 'https://netlifind.netlify.app' +const STATE_GEO_PROPERTY = 'geolocation' + +// 24 hours +const CACHE_TTL = 8.64e7 + +// 10 seconds +const REQUEST_TIMEOUT = 1e4 + +/** + * @typedef GeoLocation + * @type {object} + * @property {string} city + * @property {object} country + * @property {string} country.code + * @property {string} country.name + * @property {object} country + * @property {string} country.code + * @property {string} country.name + */ + +// The default location to be used if we're unable to talk to the API. +const mockLocation = { + city: 'San Francisco', + country: { code: 'US', name: 'United States' }, + subdivision: { code: 'CA', name: 'California' }, +} + +/** + * Returns geolocation data from a remote API, the local cache, or a mock + * location, depending on the mode selected. + * + * @param {object} params + * @param {string} params.geolocationMode + * @param {"cache"|"update"|"mock"} params.mode + * @param {boolean} params.offline + * @param {import('../utils/state-config').StateConfig} params.state + * @returns {Promise} + */ +const getGeoLocation = async ({ mode, offline, state }) => { + const cacheObject = state.get(STATE_GEO_PROPERTY) + + // If we have cached geolocation data and the `--geo` option is set to + // `cache`, let's try to use it. + if (cacheObject !== undefined && mode === 'cache') { + const age = Date.now() - cacheObject.timestamp + + // Let's use the cached data if it's not older than the TTL. Also, if the + // `--offline` option was used, it's best to use the cached location than + // the mock one. + if (age < CACHE_TTL || offline) { + return cacheObject.data + } + } + + // If the `--geo` option is set to `mock`, we use the mock location. Also, + // if the `--offline` option was used, we can't talk to the API, so let's + // also use the mock location. + if (mode === 'mock' || offline) { + return mockLocation + } + + // Trying to retrieve geolocation data from the API and caching it locally. + try { + const data = await getGeoLocationFromAPI() + const newCacheObject = { + data, + timestamp: Date.now(), + } + + state.set(STATE_GEO_PROPERTY, newCacheObject) + + return data + } catch { + // We couldn't get geolocation data from the API, so let's return the + // mock location. + return mockLocation + } +} + +/** + * Returns geolocation data from a remote API + * + * @returns {Promise} + */ +const getGeoLocationFromAPI = async () => { + const res = await fetch(API_URL, { + method: 'GET', + timeout: REQUEST_TIMEOUT, + }) + const { geo } = await res.json() + + return geo +} + +module.exports = { getGeoLocation, mockLocation } diff --git a/src/utils/proxy.js b/src/utils/proxy.js index 39ac7e76813..3355335f3e1 100644 --- a/src/utils/proxy.js +++ b/src/utils/proxy.js @@ -461,13 +461,26 @@ const onRequest = async ({ addonsUrls, edgeFunctionsProxy, functionsServer, prox proxy.web(req, res, options) } -const startProxy = async function ({ addonsUrls, config, configPath, getUpdatedConfig, projectDir, settings }) { +const startProxy = async function ({ + addonsUrls, + config, + configPath, + geolocationMode, + getUpdatedConfig, + offline, + projectDir, + settings, + state, +}) { const functionsServer = settings.functionsPort ? `http://localhost:${settings.functionsPort}` : null const edgeFunctionsProxy = await edgeFunctions.initializeProxy({ config, configPath, + geolocationMode, getUpdatedConfig, + offline, settings, + state, }) const proxy = await initializeProxy({ port: settings.frameworkPort, diff --git a/tests/integration/130.eleventy.test.js b/tests/integration/130.eleventy.test.js index cc06c063a92..57d505d15dd 100644 --- a/tests/integration/130.eleventy.test.js +++ b/tests/integration/130.eleventy.test.js @@ -2,6 +2,8 @@ const path = require('path') const test = require('ava') +const { clientIP, originalIP } = require('../lib/local-ip') + const { startDevServer } = require('./utils/dev-server') const got = require('./utils/got') @@ -59,11 +61,11 @@ test('functions rewrite echo without body', async (t) => { t.deepEqual(response.headers, { accept: 'application/json', 'accept-encoding': 'gzip, deflate, br', - 'client-ip': '127.0.0.1', + 'client-ip': clientIP, connection: 'close', host: `${host}:${port}`, 'user-agent': 'got (https://github.com/sindresorhus/got)', - 'x-forwarded-for': '::ffff:127.0.0.1', + 'x-forwarded-for': originalIP, }) t.is(response.httpMethod, 'GET') t.is(response.isBase64Encoded, true) @@ -86,13 +88,13 @@ test('functions rewrite echo with body', async (t) => { t.deepEqual(response.headers, { accept: 'application/json', 'accept-encoding': 'gzip, deflate, br', - 'client-ip': '127.0.0.1', + 'client-ip': clientIP, connection: 'close', host: `${host}:${port}`, 'content-type': 'application/x-www-form-urlencoded', 'content-length': '10', 'user-agent': 'got (https://github.com/sindresorhus/got)', - 'x-forwarded-for': '::ffff:127.0.0.1', + 'x-forwarded-for': originalIP, }) t.is(response.httpMethod, 'POST') t.is(response.isBase64Encoded, false) @@ -107,11 +109,11 @@ test('functions echo with multiple query params', async (t) => { t.deepEqual(response.headers, { accept: 'application/json', 'accept-encoding': 'gzip, deflate, br', - 'client-ip': '127.0.0.1', + 'client-ip': clientIP, connection: 'close', host: `${host}:${port}`, 'user-agent': 'got (https://github.com/sindresorhus/got)', - 'x-forwarded-for': '::ffff:127.0.0.1', + 'x-forwarded-for': originalIP, }) t.is(response.httpMethod, 'GET') t.is(response.isBase64Encoded, true) diff --git a/tests/integration/400.command.dev.test.js b/tests/integration/400.command.dev.test.js index 3ff476f3f97..4c96ba83601 100644 --- a/tests/integration/400.command.dev.test.js +++ b/tests/integration/400.command.dev.test.js @@ -8,6 +8,8 @@ const avaTest = require('ava') const { isCI } = require('ci-info') const FormData = require('form-data') +const { originalIP } = require('../lib/local-ip') + const { withDevServer } = require('./utils/dev-server') const got = require('./utils/got') const { withSiteBuilder } = require('./utils/site-builder') @@ -401,19 +403,11 @@ test('should handle form submission', async (t) => { .json() const body = JSON.parse(response.body) - - t.is(response.headers.host, `${server.host}:${server.port}`) - t.is(response.headers['content-length'], '276') - t.is(response.headers['content-type'], 'application/json') - t.is(response.httpMethod, 'POST') - t.is(response.isBase64Encoded, false) - t.is(response.path, '/') - t.deepEqual(response.queryStringParameters, { ding: 'dong' }) - t.deepEqual(body, { + const expectedBody = { payload: { created_at: body.payload.created_at, data: { - ip: '::ffff:127.0.0.1', + ip: originalIP, some: 'thing', user_agent: 'got (https://github.com/sindresorhus/got)', }, @@ -429,7 +423,16 @@ test('should handle form submission', async (t) => { ], site_url: '', }, - }) + } + + t.is(response.headers.host, `${server.host}:${server.port}`) + t.is(response.headers['content-length'], JSON.stringify(expectedBody).length.toString()) + t.is(response.headers['content-type'], 'application/json') + t.is(response.httpMethod, 'POST') + t.is(response.isBase64Encoded, false) + t.is(response.path, '/') + t.deepEqual(response.queryStringParameters, { ding: 'dong' }) + t.deepEqual(body, expectedBody) }) }) }) diff --git a/tests/integration/500.command.dev.test.js b/tests/integration/500.command.dev.test.js index 7d050f4b20b..2d227bdb143 100644 --- a/tests/integration/500.command.dev.test.js +++ b/tests/integration/500.command.dev.test.js @@ -7,6 +7,8 @@ const avaTest = require('ava') const { isCI } = require('ci-info') const FormData = require('form-data') +const { originalIP } = require('../lib/local-ip') + const { withDevServer } = require('./utils/dev-server') const got = require('./utils/got') const { withSiteBuilder } = require('./utils/site-builder') @@ -96,19 +98,11 @@ test('should handle form submission', async (t) => { .json() const body = JSON.parse(response.body) - - t.is(response.headers.host, `${server.host}:${server.port}`) - t.is(response.headers['content-length'], '276') - t.is(response.headers['content-type'], 'application/json') - t.is(response.httpMethod, 'POST') - t.is(response.isBase64Encoded, false) - t.is(response.path, '/') - t.deepEqual(response.queryStringParameters, { ding: 'dong' }) - t.deepEqual(body, { + const expectedBody = { payload: { created_at: body.payload.created_at, data: { - ip: '::ffff:127.0.0.1', + ip: originalIP, some: 'thing', user_agent: 'got (https://github.com/sindresorhus/got)', }, @@ -124,7 +118,16 @@ test('should handle form submission', async (t) => { ], site_url: '', }, - }) + } + + t.is(response.headers.host, `${server.host}:${server.port}`) + t.is(response.headers['content-length'], JSON.stringify(expectedBody).length.toString()) + t.is(response.headers['content-type'], 'application/json') + t.is(response.httpMethod, 'POST') + t.is(response.isBase64Encoded, false) + t.is(response.path, '/') + t.deepEqual(response.queryStringParameters, { ding: 'dong' }) + t.deepEqual(body, expectedBody) }) }) }) diff --git a/tests/integration/510.hugo.test.js b/tests/integration/510.hugo.test.js index bc7df3e6e1c..d31fa9f78c2 100644 --- a/tests/integration/510.hugo.test.js +++ b/tests/integration/510.hugo.test.js @@ -1,28 +1,45 @@ const path = require('path') +const process = require('process') const test = require('ava') +const semver = require('semver') const { startDevServer } = require('./utils/dev-server') const got = require('./utils/got') -test.before(async (t) => { - const server = await startDevServer({ - cwd: path.join(__dirname, 'hugo-site'), - // required so configuration won't be resolved from the current CLI repo linked site - args: ['--offline'], +const version = process.version.slice(1) + +// This test is currently failing on Node 17 and above due to +// https://github.com/netlify/cli/issues/3617. +if (semver.lt(version, '17.0.0')) { + test.before(async (t) => { + const server = await startDevServer({ + cwd: path.join(__dirname, 'hugo-site'), + // required so configuration won't be resolved from the current CLI repo linked site + args: ['--offline'], + env: { + HUGO_CACHEDIR: '/tmp', + }, + }) + + t.context.server = server }) - t.context.server = server -}) + test.after(async (t) => { + const { server } = t.context + await server.close() + }) -test.after(async (t) => { - const { server } = t.context - await server.close() -}) + test('should not infinite redirect when -d flag is passed', async (t) => { + const { url } = t.context.server + const response = await got(`${url}/`).text() -test('should not infinite redirect when -d flag is passed', async (t) => { - const { url } = t.context.server - const response = await got(`${url}/`).text() + t.true(response.includes('Home page!')) + }) +} - t.true(response.includes('Home page!')) +// This feels awkward, but ava will throw if we have a file with no tests, so +// we need something to run in case the test above is skipped. +test('dummy test', (t) => { + t.true(true) }) diff --git a/tests/lib/local-ip.js b/tests/lib/local-ip.js new file mode 100644 index 00000000000..76ade40de06 --- /dev/null +++ b/tests/lib/local-ip.js @@ -0,0 +1,10 @@ +const process = require('process') + +const semver = require('semver') + +const version = process.version.slice(1) + +// This changed in Node 17 with https://github.com/nodejs/node/pull/39987. +const [clientIP, originalIP] = semver.gte(version, '17.0.0') ? ['::1', '::1'] : ['127.0.0.1', '::ffff:127.0.0.1'] + +module.exports = { clientIP, originalIP } diff --git a/tests/unit/lib/geo-location.test.js b/tests/unit/lib/geo-location.test.js new file mode 100644 index 00000000000..8b47cdce279 --- /dev/null +++ b/tests/unit/lib/geo-location.test.js @@ -0,0 +1,118 @@ +const test = require('ava') +const nock = require('nock') + +const { getGeoLocation, mockLocation } = require('../../../src/lib/geo-location') + +test('`getGeoLocation` returns geolocation data from the API if `mode: "cache"`', async (t) => { + let hasCalledStateSet = false + + const testLocation = { + city: 'Netlify Town', + country: { code: 'NF', name: 'Netlify' }, + subdivision: { code: 'JS', name: 'Jamstack' }, + } + const mockState = { + get() {}, + set(key, value) { + hasCalledStateSet = true + + t.is(key, 'geolocation') + t.deepEqual(value.data, testLocation) + t.is(typeof value.timestamp, 'number') + }, + } + const mockRequest = nock('https://netlifind.netlify.app').get('/').reply(200, { + geo: testLocation, + }) + const geo = await getGeoLocation({ mode: 'cache', state: mockState }) + + t.true(mockRequest.isDone()) + t.true(hasCalledStateSet) + t.deepEqual(geo, testLocation) +}) + +test('`getGeoLocation` returns geolocation data from cache if data is younger than TTL', async (t) => { + let hasCalledStateSet = false + + const testLocation = { + city: 'Netlify Town', + country: { code: 'NF', name: 'Netlify' }, + subdivision: { code: 'JS', name: 'Jamstack' }, + } + const mockState = { + get(key) { + t.is(key, 'geolocation') + + return { + data: testLocation, + timestamp: Date.now() - 3, + } + }, + set() { + hasCalledStateSet = true + }, + } + const mockRequest = nock('https://netlifind.netlify.app').get('/').reply(200, { + geo: testLocation, + }) + const geo = await getGeoLocation({ mode: 'cache', state: mockState }) + + t.false(mockRequest.isDone()) + t.false(hasCalledStateSet) + t.deepEqual(geo, testLocation) +}) + +test('`getGeoLocation` returns geolocation data from cache, even if older than TTL, if the application is offline', async (t) => { + let hasCalledStateSet = false + + const testLocation = { + city: 'Netlify Town', + country: { code: 'NF', name: 'Netlify' }, + subdivision: { code: 'JS', name: 'Jamstack' }, + } + const mockState = { + get(key) { + t.is(key, 'geolocation') + + return { + data: testLocation, + timestamp: 0, + } + }, + set() { + hasCalledStateSet = true + }, + } + const mockRequest = nock('https://netlifind.netlify.app').get('/').reply(200, { + geo: testLocation, + }) + const geo = await getGeoLocation({ mode: 'cache', offline: true, state: mockState }) + + t.false(mockRequest.isDone()) + t.false(hasCalledStateSet) + t.deepEqual(geo, testLocation) +}) + +test('`getGeoLocation` returns mock geolocation data if `mode: "mock"`', async (t) => { + let hasCalledStateSet = false + + const testLocation = { + city: 'Netlify Town', + country: { code: 'NF', name: 'Netlify' }, + subdivision: { code: 'JS', name: 'Jamstack' }, + } + const mockState = { + get() {}, + set() { + hasCalledStateSet = true + }, + } + const mockRequest = nock('https://netlifind.netlify.app').get('/').reply(200, { + geo: testLocation, + }) + const geo = await getGeoLocation({ mode: 'mock', state: mockState }) + + t.false(mockRequest.isDone()) + t.false(hasCalledStateSet) + t.deepEqual(geo, mockLocation) +})