diff --git a/auth.js b/auth.js index e096a6f9..cf76fdb6 100644 --- a/auth.js +++ b/auth.js @@ -1,55 +1,94 @@ 'use strict' +const npa = require('npm-package-arg') -const defaultOpts = require('./default-opts.js') -const url = require('url') +// Find the longest registry key that is used for some kind of auth +// in the options. +const regKeyFromURI = (uri, opts) => { + const parsed = new URL(uri) + // try to find a config key indicating we have auth for this registry + // can be one of :_authToken, :_auth, or :_password and :username + // We walk up the "path" until we're left with just //[:], + // stopping when we reach '//'. + let regKey = `//${parsed.host}${parsed.pathname}` + while (regKey.length > '//'.length) { + // got some auth for this URI + if (hasAuth(regKey, opts)) + return regKey -module.exports = getAuth -function getAuth (registry, opts_ = {}) { - if (!registry) - throw new Error('registry is required') - const opts = opts_.forceAuth ? opts_.forceAuth : { ...defaultOpts, ...opts_ } - const AUTH = {} - const regKey = registry && registryKey(registry) - const doKey = (key, alias) => addKey(opts, AUTH, regKey, key, alias) - doKey('token') - doKey('_authToken', 'token') - doKey('username') - doKey('password') - doKey('_password', 'password') - doKey('email') - doKey('_auth') - doKey('otp') - doKey('always-auth', 'alwaysAuth') - if (AUTH.password) - AUTH.password = Buffer.from(AUTH.password, 'base64').toString('utf8') - - if (AUTH._auth && !(AUTH.username && AUTH.password)) { - let auth = Buffer.from(AUTH._auth, 'base64').toString() - auth = auth.split(':') - AUTH.username = auth.shift() - AUTH.password = auth.join(':') + // can be either //host/some/path/:_auth or //host/some/path:_auth + // walk up by removing EITHER what's after the slash OR the slash itself + regKey = regKey.replace(/([^/]+|\/)$/, '') } - AUTH.alwaysAuth = AUTH.alwaysAuth === 'false' ? false : !!AUTH.alwaysAuth - return AUTH } -function addKey (opts, obj, scope, key, objKey) { - if (opts[key]) - obj[objKey || key] = opts[key] +const hasAuth = (regKey, opts) => ( + opts[`${regKey}:_authToken`] || + opts[`${regKey}:_auth`] || + opts[`${regKey}:username`] && opts[`${regKey}:_password`] +) - if (scope && opts[`${scope}:${key}`]) - obj[objKey || key] = opts[`${scope}:${key}`] -} +const getAuth = (uri, opts = {}) => { + const { forceAuth } = opts + if (!uri) + throw new Error('URI is required') + const regKey = regKeyFromURI(uri, forceAuth || opts) + + // we are only allowed to use what's in forceAuth if specified + if (forceAuth && !regKey) { + return new Auth({ + scopeAuthKey: null, + token: forceAuth._authToken, + username: forceAuth.username, + password: forceAuth._password || forceAuth.password, + auth: forceAuth._auth || forceAuth.auth, + }) + } + + // no auth for this URI + if (!regKey && opts.spec) { + // If making a tarball request to a different base URI than the + // registry where we logged in, but the same auth SHOULD be sent + // to that artifact host, then we track where it was coming in from, + // and warn the user if we get a 4xx error on it. + const { spec } = opts + const { scope: specScope, subSpec } = npa(spec) + const subSpecScope = subSpec && subSpec.scope + const scope = subSpec ? subSpecScope : specScope + const scopeReg = scope && opts[`${scope}:registry`] + const scopeAuthKey = scopeReg && regKeyFromURI(scopeReg, opts) + return new Auth({ scopeAuthKey }) + } -// Called a nerf dart in the main codebase. Used as a "safe" -// key when fetching registry info from config. -function registryKey (registry) { - const parsed = new url.URL(registry) - const formatted = url.format({ - protocol: parsed.protocol, - host: parsed.host, - pathname: parsed.pathname, - slashes: true, + const { + [`${regKey}:_authToken`]: token, + [`${regKey}:username`]: username, + [`${regKey}:_password`]: password, + [`${regKey}:_auth`]: auth, + } = opts + + return new Auth({ + scopeAuthKey: null, + token, + auth, + username, + password, }) - return url.format(new url.URL('.', formatted)).replace(/^[^:]+:/, '') } + +class Auth { + constructor ({ token, auth, username, password, scopeAuthKey }) { + this.scopeAuthKey = scopeAuthKey + this.token = null + this.auth = null + if (token) + this.token = token + else if (auth) + this.auth = auth + else if (username && password) { + const p = Buffer.from(password, 'base64').toString('utf8') + this.auth = Buffer.from(`${username}:${p}`, 'utf8').toString('base64') + } + } +} + +module.exports = getAuth diff --git a/check-response.js b/check-response.js index 5154da53..7610e0d7 100644 --- a/check-response.js +++ b/check-response.js @@ -5,15 +5,25 @@ const LRU = require('lru-cache') const { Response } = require('minipass-fetch') const defaultOpts = require('./default-opts.js') -module.exports = checkResponse -function checkResponse (method, res, registry, startTime, opts_ = {}) { - const opts = { ...defaultOpts, ...opts_ } +const checkResponse = async ({ method, uri, res, registry, startTime, auth, opts }) => { + opts = { ...defaultOpts, ...opts } if (res.headers.has('npm-notice') && !res.headers.has('x-local-cache')) opts.log.notice('', res.headers.get('npm-notice')) checkWarnings(res, registry, opts) if (res.status >= 400) { logRequest(method, res, startTime, opts) + if (auth && auth.scopeAuthKey && !auth.token && !auth.auth) { + // we didn't have auth for THIS request, but we do have auth for + // requests to the registry indicated by the spec's scope value. + // Warn the user. + opts.log.warn('registry', `No auth for URI, but auth present for scoped registry. + +URI: ${uri} +Scoped Registry Key: ${auth.scopeAuthKey} + +More info here: https://github.com/npm/cli/wiki/No-auth-for-URI,-but-auth-present-for-scoped-registry`) + } return checkErrors(method, res, startTime, opts) } else { res.body.on('end', () => logRequest(method, res, startTime, opts)) @@ -24,6 +34,7 @@ function checkResponse (method, res, registry, startTime, opts_ = {}) { return res } } +module.exports = checkResponse function logRequest (method, res, startTime, opts) { const elapsedTime = Date.now() - startTime diff --git a/errors.js b/errors.js index 69671551..e65e5fbd 100644 --- a/errors.js +++ b/errors.js @@ -22,6 +22,7 @@ function packageName (href) { class HttpErrorBase extends Error { constructor (method, res, body, spec) { super() + this.name = this.constructor.name this.headers = res.headers.raw() this.statusCode = res.status this.code = `E${res.status}` diff --git a/index.js b/index.js index df3b49eb..283348f6 100644 --- a/index.js +++ b/index.js @@ -27,26 +27,32 @@ function regFetch (uri, /* istanbul ignore next */ opts_ = {}) { ...defaultOpts, ...opts_, } - const registry = opts.registry = ( - (opts.spec && pickRegistry(opts.spec, opts)) || - opts.registry || - /* istanbul ignore next */ - 'https://registry.npmjs.org/' - ) - - if (!urlIsValid(uri)) { + + // if we did not get a fully qualified URI, then we look at the registry + // config or relevant scope to resolve it. + const uriValid = urlIsValid(uri) + let registry = opts.registry || defaultOpts.registry + if (!uriValid) { + registry = opts.registry = ( + (opts.spec && pickRegistry(opts.spec, opts)) || + opts.registry || + registry + ) uri = `${ registry.trim().replace(/\/?$/g, '') }/${ uri.trim().replace(/^\//, '') }` + // asserts that this is now valid + new url.URL(uri) } const method = opts.method || 'GET' // through that takes into account the scope, the prefix of `uri`, etc const startTime = Date.now() - const headers = getHeaders(registry, uri, opts) + const auth = getAuth(uri, opts) + const headers = getHeaders(uri, auth, opts) let body = opts.body const bodyIsStream = Minipass.isStream(body) const bodyIsPromise = body && @@ -117,9 +123,15 @@ function regFetch (uri, /* istanbul ignore next */ opts_ = {}) { }, strictSSL: opts.strictSSL, timeout: opts.timeout || 30 * 1000, - }).then(res => checkResponse( - method, res, registry, startTime, opts - )) + }).then(res => checkResponse({ + method, + uri, + res, + registry, + startTime, + auth, + opts, + })) return Promise.resolve(body).then(doFetch) } @@ -151,7 +163,7 @@ function pickRegistry (spec, opts = {}) { registry = opts[opts.scope.replace(/^@?/, '@') + ':registry'] if (!registry) - registry = opts.registry || 'https://registry.npmjs.org/' + registry = opts.registry || defaultOpts.registry return registry } @@ -163,7 +175,7 @@ function getCacheMode (opts) { : 'default' } -function getHeaders (registry, uri, opts) { +function getHeaders (uri, auth, opts) { const headers = Object.assign({ 'npm-in-ci': !!opts.isFromCI, 'user-agent': opts.userAgent, @@ -178,25 +190,15 @@ function getHeaders (registry, uri, opts) { if (opts.npmCommand) headers['npm-command'] = opts.npmCommand - const auth = getAuth(registry, opts) // If a tarball is hosted on a different place than the manifest, only send // credentials on `alwaysAuth` - const shouldAuth = ( - auth.alwaysAuth || - new url.URL(uri).host === new url.URL(registry).host - ) - if (shouldAuth && auth.token) + if (auth.token) headers.authorization = `Bearer ${auth.token}` - else if (shouldAuth && auth.username && auth.password) { - const encoded = Buffer.from( - `${auth.username}:${auth.password}`, 'utf8' - ).toString('base64') - headers.authorization = `Basic ${encoded}` - } else if (shouldAuth && auth._auth) - headers.authorization = `Basic ${auth._auth}` - - if (shouldAuth && auth.otp) - headers['npm-otp'] = auth.otp + else if (auth.auth) + headers.authorization = `Basic ${auth.auth}` + + if (opts.otp) + headers['npm-otp'] = opts.otp return headers } diff --git a/test/auth.js b/test/auth.js index 711ef506..4e43a7fd 100644 --- a/test/auth.js +++ b/test/auth.js @@ -1,7 +1,7 @@ 'use strict' const npmlog = require('npmlog') -const test = require('tap').test +const t = require('tap') const tnock = require('./util/tnock.js') const fetch = require('../index.js') @@ -20,21 +20,21 @@ const OPTS = { registry: 'https://mock.reg/', } -test('basic auth', t => { +t.test('basic auth', t => { const config = { registry: 'https://my.custom.registry/here/', username: 'globaluser', password: Buffer.from('globalpass', 'utf8').toString('base64'), email: 'global@ma.il', '//my.custom.registry/here/:username': 'user', - '//my.custom.registry/here/:password': Buffer.from('pass', 'utf8').toString('base64'), + '//my.custom.registry/here/:_password': Buffer.from('pass', 'utf8').toString('base64'), '//my.custom.registry/here/:email': 'e@ma.il', } - t.deepEqual(getAuth(config.registry, config), { - alwaysAuth: false, - username: 'user', - password: 'pass', - email: 'e@ma.il', + const gotAuth = getAuth(config.registry, config) + t.same(gotAuth, { + scopeAuthKey: null, + token: null, + auth: Buffer.from('user:pass').toString('base64'), }, 'basic auth details generated') const opts = Object.assign({}, OPTS, config) @@ -50,16 +50,19 @@ test('basic auth', t => { .then(res => t.equal(res, 'success', 'basic auth succeeded')) }) -test('token auth', t => { +t.test('token auth', t => { const config = { registry: 'https://my.custom.registry/here/', token: 'deadbeef', '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', + '//my.custom.registry/:_authToken': 'c0ffee', + '//my.custom.registry/:token': 'nope', } - t.deepEqual(getAuth(config.registry, config), { - alwaysAuth: false, + t.same(getAuth(`${config.registry}/foo/-/foo.tgz`, config), { + scopeAuthKey: null, token: 'c0ffee', + auth: null, }, 'correct auth token picked out') const opts = Object.assign({}, OPTS, config) @@ -74,7 +77,7 @@ test('token auth', t => { .then(res => t.equal(res, 'success', 'token auth succeeded')) }) -test('forceAuth', t => { +t.test('forceAuth', t => { const config = { registry: 'https://my.custom.registry/here/', token: 'deadbeef', @@ -88,11 +91,10 @@ test('forceAuth', t => { 'always-auth': true, }, } - t.deepEqual(getAuth(config.registry, config), { - alwaysAuth: true, - username: 'user', - password: 'pass', - email: 'e@ma.il', + t.same(getAuth(config.registry, config), { + scopeAuthKey: null, + token: null, + auth: Buffer.from('user:pass').toString('base64'), }, 'only forceAuth details included') const opts = Object.assign({}, OPTS, config) @@ -108,15 +110,17 @@ test('forceAuth', t => { .then(res => t.equal(res, 'success', 'used forced auth details')) }) -test('_auth auth', t => { +t.test('_auth auth', t => { const config = { registry: 'https://my.custom.registry/here/', _auth: 'deadbeef', + '//my.custom.registry/:_auth': 'decafbad', '//my.custom.registry/here/:_auth': 'c0ffee', } - t.like(getAuth(config.registry, config), { - alwaysAuth: false, - _auth: 'c0ffee', + t.same(getAuth(`${config.registry}/asdf/foo/bar/baz`, config), { + scopeAuthKey: null, + token: null, + auth: 'c0ffee', }, 'correct _auth picked out') const opts = Object.assign({}, OPTS, config) @@ -128,7 +132,7 @@ test('_auth auth', t => { .then(res => t.equal(res, 'success', '_auth auth succeeded')) }) -test('_auth username:pass auth', t => { +t.test('_auth username:pass auth', t => { const username = 'foo' const password = 'bar' const auth = Buffer.from(`${username}:${password}`, 'utf8').toString('base64') @@ -137,11 +141,10 @@ test('_auth username:pass auth', t => { _auth: 'foobarbaz', '//my.custom.registry/here/:_auth': auth, } - t.like(getAuth(config.registry, config), { - alwaysAuth: false, - username, - password, - _auth: auth, + t.same(getAuth(config.registry, config), { + scopeAuthKey: null, + token: null, + auth: auth, }, 'correct _auth picked out') const opts = Object.assign({}, OPTS, config) @@ -153,71 +156,72 @@ test('_auth username:pass auth', t => { .then(res => t.equal(res, 'success', '_auth auth succeeded')) }) -test('_auth only sets user/pass when not already set', t => { +t.test('ignore user/pass when _auth is set', t => { const username = 'foo' const password = Buffer.from('bar', 'utf8').toString('base64') - const _auth = Buffer.from('not:foobar', 'utf8').toString('base64') + const auth = Buffer.from('not:foobar', 'utf8').toString('base64') const config = { - _auth, - username, - password, + '//registry/:_auth': auth, + '//registry/:username': username, + '//registry/:password': password, 'always-auth': 'false', } const expect = { - _auth, - username, - password: 'bar', - alwaysAuth: false, + scopeAuthKey: null, + auth, + token: null, } - t.match(getAuth('http://registry/', config), expect) + t.match(getAuth('http://registry/pkg/-/pkg-1.2.3.tgz', config), expect) t.end() }) -test('globally-configured auth', t => { +t.test('globally-configured auth', t => { const basicConfig = { registry: 'https://different.registry/', - username: 'globaluser', - password: Buffer.from('globalpass', 'utf8').toString('base64'), - email: 'global@ma.il', + '//different.registry/:username': 'globaluser', + '//different.registry/:_password': Buffer.from('globalpass', 'utf8').toString('base64'), + '//different.registry/:email': 'global@ma.il', '//my.custom.registry/here/:username': 'user', - '//my.custom.registry/here/:password': Buffer.from('pass', 'utf8').toString('base64'), + '//my.custom.registry/here/:_password': Buffer.from('pass', 'utf8').toString('base64'), '//my.custom.registry/here/:email': 'e@ma.il', } - t.deepEqual(getAuth(basicConfig.registry, basicConfig), { - alwaysAuth: false, - username: 'globaluser', - password: 'globalpass', - email: 'global@ma.il', + t.same(getAuth(basicConfig.registry, basicConfig), { + scopeAuthKey: null, + token: null, + auth: Buffer.from('globaluser:globalpass').toString('base64'), }, 'basic auth details generated from global settings') const tokenConfig = { registry: 'https://different.registry/', - _authToken: 'deadbeef', + '//different.registry/:_authToken': 'deadbeef', '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', } - t.deepEqual(getAuth(tokenConfig.registry, tokenConfig), { - alwaysAuth: false, + t.same(getAuth(tokenConfig.registry, tokenConfig), { + scopeAuthKey: null, token: 'deadbeef', + auth: null, }, 'correct global auth token picked out') const _authConfig = { registry: 'https://different.registry/', - _auth: 'deadbeef', + '//different.registry:_auth': 'deadbeef', + '//different.registry/bar:_auth': 'incorrect', '//my.custom.registry/here/:_auth': 'c0ffee', } - t.like(getAuth(_authConfig.registry, _authConfig), { - alwaysAuth: false, - _auth: 'deadbeef', - }, 'correct global _auth picked out') + t.same(getAuth(`${_authConfig.registry}/foo`, _authConfig), { + scopeAuthKey: null, + token: null, + auth: 'deadbeef', + }, 'correct _auth picked out') - t.done() + t.end() }) -test('otp token passed through', t => { +t.test('otp token passed through', t => { const config = { registry: 'https://my.custom.registry/here/', token: 'deadbeef', @@ -225,10 +229,10 @@ test('otp token passed through', t => { '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', } - t.deepEqual(getAuth(config.registry, config), { - alwaysAuth: false, + t.same(getAuth(config.registry, config), { + scopeAuthKey: null, token: 'c0ffee', - otp: '694201', + auth: null, }, 'correct auth token picked out') const opts = Object.assign({}, OPTS, config) @@ -244,7 +248,7 @@ test('otp token passed through', t => { .then(res => t.equal(res, 'success', 'otp auth succeeded')) }) -test('different hosts for uri vs registry', t => { +t.test('different hosts for uri vs registry', t => { const config = { 'always-auth': false, registry: 'https://my.custom.registry/here/', @@ -265,7 +269,7 @@ test('different hosts for uri vs registry', t => { .then(res => t.equal(res, 'success', 'token auth succeeded')) }) -test('http vs https auth sending', t => { +t.test('http vs https auth sending', t => { const config = { 'always-auth': false, registry: 'https://my.custom.registry/here/', @@ -283,29 +287,30 @@ test('http vs https auth sending', t => { .then(res => t.equal(res, 'success', 'token auth succeeded')) }) -test('always-auth', t => { +t.test('always-auth', t => { const config = { registry: 'https://my.custom.registry/here/', 'always-auth': 'true', - token: 'deadbeef', + '//some.other.host/:_authToken': 'deadbeef', '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', } - t.deepEqual(getAuth(config.registry, config), { - alwaysAuth: true, + t.same(getAuth(config.registry, config), { + scopeAuthKey: null, token: 'c0ffee', + auth: null, }, 'correct auth token picked out') const opts = Object.assign({}, OPTS, config) tnock(t, 'https://some.other.host/') - .matchHeader('authorization', 'Bearer c0ffee') + .matchHeader('authorization', 'Bearer deadbeef') .get('/hello') .reply(200, '"success"') return fetch.json('https://some.other.host/hello', opts) .then(res => t.equal(res, 'success', 'token auth succeeded')) }) -test('scope-based auth', t => { +t.test('scope-based auth', t => { const config = { registry: 'https://my.custom.registry/here/', scope: '@myscope', @@ -314,12 +319,14 @@ test('scope-based auth', t => { '//my.custom.registry/here/:_authToken': 'c0ffee', '//my.custom.registry/here/:token': 'nope', } - t.deepEqual(getAuth(config['@myscope:registry'], config), { - alwaysAuth: false, + t.same(getAuth(config['@myscope:registry'], config), { + scopeAuthKey: null, + auth: null, token: 'c0ffee', }, 'correct auth token picked out') - t.deepEqual(getAuth(config['@myscope:registry'], config), { - alwaysAuth: false, + t.same(getAuth(config['@myscope:registry'], config), { + scopeAuthKey: null, + auth: null, token: 'c0ffee', }, 'correct auth token picked out without scope config having an @') @@ -340,7 +347,66 @@ test('scope-based auth', t => { .then(res => t.equal(res, 'success', 'token auth succeeded without @ in scope')) }) -test('auth needs a registry', t => { - t.throws(() => getAuth(null), { message: 'registry is required' }) +t.test('auth needs a uri', t => { + t.throws(() => getAuth(null), { message: 'URI is required' }) + t.end() +}) + +t.test('do not be thrown by other weird configs', t => { + const opts = { + scope: '@asdf', + '@asdf:_authToken': 'does this work?', + '//registry.npmjs.org:_authToken': 'do not share this', + _authToken: 'definitely do not share this, either', + '//localhost:15443:_authToken': 'wrong', + '//localhost:15443/foo:_authToken': 'correct bearer token', + '//localhost:_authToken': 'not this one', + '//other-registry:_authToken': 'this should not be used', + '@asdf:registry': 'https://other-registry/', + spec: '@asdf/foo', + } + const uri = 'http://localhost:15443/foo/@asdf/bar/-/bar-1.2.3.tgz' + const auth = getAuth(uri, opts) + t.same(auth, { + scopeAuthKey: null, + token: 'correct bearer token', + auth: null, + }) + t.end() +}) + +t.test('scopeAuthKey tests', t => { + const opts = { + '@other-scope:registry': 'https://other-scope-registry.com/', + '//other-scope-registry.com/:_authToken': 'cafebad', + '@scope:registry': 'https://scope-host.com/', + '//scope-host.com/:_authToken': 'c0ffee', + } + const uri = 'https://tarball-host.com/foo/foo.tgz' + + t.same(getAuth(uri, { ...opts, spec: '@scope/foo@latest' }), { + scopeAuthKey: '//scope-host.com/', + auth: null, + token: null, + }, 'regular scoped spec') + + t.same(getAuth(uri, { ...opts, spec: 'foo@npm:@scope/foo@latest' }), { + scopeAuthKey: '//scope-host.com/', + auth: null, + token: null, + }, 'scoped pkg aliased to unscoped name') + + t.same(getAuth(uri, { ...opts, spec: '@other-scope/foo@npm:@scope/foo@latest' }), { + scopeAuthKey: '//scope-host.com/', + auth: null, + token: null, + }, 'scoped name aliased to other scope with auth') + + t.same(getAuth(uri, { ...opts, spec: '@scope/foo@npm:foo@latest' }), { + scopeAuthKey: null, + auth: null, + token: null, + }, 'unscoped aliased to scoped name') + t.end() }) diff --git a/test/cache.js b/test/cache.js index bacd72f1..de7c08fa 100644 --- a/test/cache.js +++ b/test/cache.js @@ -4,7 +4,7 @@ const { promisify } = require('util') const statAsync = promisify(require('fs').stat) const npmlog = require('npmlog') const path = require('path') -const test = require('tap').test +const t = require('tap') const tnock = require('./util/tnock.js') const fetch = require('../index.js') @@ -27,57 +27,57 @@ const OPTS = { registry: REGISTRY, } -test('can cache GET requests', t => { +t.test('can cache GET requests', t => { tnock(t, REGISTRY) .get('/normal') .times(1) .reply(200, { obj: 'value' }) return fetch.json('/normal', OPTS) - .then(val => t.deepEqual(val, { obj: 'value' }, 'got expected response')) + .then(val => t.same(val, { obj: 'value' }, 'got expected response')) .then(() => statAsync(OPTS.cache)) .then(stat => t.ok(stat.isDirectory(), 'cache directory created')) .then(() => fetch.json('/normal', OPTS)) - .then(val => t.deepEqual(val, { obj: 'value' }, 'response was cached')) + .then(val => t.same(val, { obj: 'value' }, 'response was cached')) }) -test('preferOffline', t => { +t.test('preferOffline', t => { tnock(t, REGISTRY) .get('/preferOffline') .times(1) .reply(200, { obj: 'value' }) return fetch.json('/preferOffline', { ...OPTS, preferOffline: true }) - .then(val => t.deepEqual(val, { obj: 'value' }, 'got expected response')) + .then(val => t.same(val, { obj: 'value' }, 'got expected response')) .then(() => statAsync(OPTS.cache)) .then(stat => t.ok(stat.isDirectory(), 'cache directory created')) .then(() => fetch.json('/preferOffline', { ...OPTS, preferOffline: true })) - .then(val => t.deepEqual(val, { obj: 'value' }, 'response was cached')) + .then(val => t.same(val, { obj: 'value' }, 'response was cached')) }) -test('offline', t => { +t.test('offline', t => { tnock(t, REGISTRY) .get('/offline') .times(1) .reply(200, { obj: 'value' }) return fetch.json('/offline', OPTS) - .then(val => t.deepEqual(val, { obj: 'value' }, 'got expected response')) + .then(val => t.same(val, { obj: 'value' }, 'got expected response')) .then(() => statAsync(OPTS.cache)) .then(stat => t.ok(stat.isDirectory(), 'cache directory created')) .then(() => fetch.json('/offline', { ...OPTS, offline: true })) - .then(val => t.deepEqual(val, { obj: 'value' }, 'response was cached')) + .then(val => t.same(val, { obj: 'value' }, 'response was cached')) }) -test('offline fails if not cached', t => +t.test('offline fails if not cached', t => t.rejects(() => fetch('/offline-fails', { ...OPTS, offline: true }))) -test('preferOnline', t => { +t.test('preferOnline', t => { tnock(t, REGISTRY) .get('/preferOnline') .times(2) .reply(200, { obj: 'value' }) return fetch.json('/preferOnline', OPTS) - .then(val => t.deepEqual(val, { obj: 'value' }, 'got expected response')) + .then(val => t.same(val, { obj: 'value' }, 'got expected response')) .then(() => statAsync(OPTS.cache)) .then(stat => t.ok(stat.isDirectory(), 'cache directory created')) .then(() => fetch.json('/preferOnline', { ...OPTS, preferOnline: true })) - .then(val => t.deepEqual(val, { obj: 'value' }, 'response was refetched')) + .then(val => t.same(val, { obj: 'value' }, 'response was refetched')) }) diff --git a/test/check-response.js b/test/check-response.js index 1b9d17cf..6e1e8040 100644 --- a/test/check-response.js +++ b/test/check-response.js @@ -1,9 +1,11 @@ const { Readable } = require('stream') -const test = require('tap').test +const t = require('tap') const checkResponse = require('../check-response.js') -const errors = require('./errors.js') +const errors = require('../errors.js') const silentLog = require('../silentlog.js') +const registry = 'registry' +const startTime = Date.now() class Body extends Readable { _read () { @@ -22,42 +24,62 @@ const mockFetchRes = { status: 200, } -test('any response error should be silent', t => { +t.test('any response error should be silent', t => { const res = Object.assign({}, mockFetchRes, { buffer: () => Promise.reject(new Error('ERR')), status: 400, + url: 'https://example.com/', }) - t.rejects(checkResponse('get', res, 'registry', Date.now(), { ignoreBody: true }), errors.HttpErrorGeneral) + + t.rejects(checkResponse({ + method: 'get', + res, + registry, + startTime, + opts: { ignoreBody: true }, + }), errors.HttpErrorGeneral) t.end() }) -test('all checks are ok, nothing to report', t => { +t.test('all checks are ok, nothing to report', t => { const res = Object.assign({}, mockFetchRes, { buffer: () => Promise.resolve(Buffer.from('ok')), status: 400, + url: 'https://example.com/', }) - t.rejects(checkResponse('get', res, 'registry', Date.now()), errors.HttpErrorGeneral) + t.rejects(checkResponse({ + method: 'get', + res, + registry, + startTime, + }), errors.HttpErrorGeneral) t.end() }) -test('log x-fetch-attempts header value', t => { +t.test('log x-fetch-attempts header value', t => { const headers = new Headers() headers.get = header => header === 'x-fetch-attempts' ? 3 : undefined const res = Object.assign({}, mockFetchRes, { headers, status: 400, }) - t.rejects(checkResponse('get', res, 'registry', Date.now(), { - log: Object.assign({}, silentLog, { - http (header, msg) { - t.ok(msg.endsWith('attempt #3'), 'should log correct number of attempts') - }, - }), - })) t.plan(2) + t.rejects(checkResponse({ + method: 'get', + res, + registry, + startTime, + opts: { + log: Object.assign({}, silentLog, { + http (header, msg) { + t.ok(msg.endsWith('attempt #3'), 'should log correct number of attempts') + }, + }), + }, + })) }) -test('log the url fetched', async t => { +t.test('log the url fetched', t => { const headers = new Headers() const EE = require('events') headers.get = header => undefined @@ -67,18 +89,25 @@ test('log the url fetched', async t => { url: 'http://example.com/foo/bar/baz', body: new EE(), }) - checkResponse('get', res, 'registry', Date.now(), { - log: Object.assign({}, silentLog, { - http (header, msg) { - t.equal(header, 'fetch') - t.match(msg, /^GET 200 http:\/\/example.com\/foo\/bar\/baz [0-9]+m?s/) - }, - }), + t.plan(2) + checkResponse({ + method: 'get', + res, + registry, + startTime, + opts: { + log: Object.assign({}, silentLog, { + http (header, msg) { + t.equal(header, 'fetch') + t.match(msg, /^GET 200 http:\/\/example.com\/foo\/bar\/baz [0-9]+m?s/) + }, + }), + }, }) res.body.emit('end') }) -test('redact password from log', async t => { +t.test('redact password from log', t => { const headers = new Headers() const EE = require('events') headers.get = header => undefined @@ -88,18 +117,25 @@ test('redact password from log', async t => { url: 'http://username:password@example.com/foo/bar/baz', body: new EE(), }) - checkResponse('get', res, 'registry', Date.now(), { - log: Object.assign({}, silentLog, { - http (header, msg) { - t.equal(header, 'fetch') - t.match(msg, /^GET 200 http:\/\/username:\*\*\*@example.com\/foo\/bar\/baz [0-9]+m?s/) - }, - }), + t.plan(2) + checkResponse({ + method: 'get', + res, + registry, + startTime, + opts: { + log: Object.assign({}, silentLog, { + http (header, msg) { + t.equal(header, 'fetch') + t.match(msg, /^GET 200 http:\/\/username:\*\*\*@example.com\/foo\/bar\/baz [0-9]+m?s/) + }, + }), + }, }) res.body.emit('end') }) -test('bad-formatted warning headers', t => { +t.test('bad-formatted warning headers', t => { const headers = new Headers() headers.has = header => header === 'warning' ? 'foo' : undefined headers.raw = () => ({ @@ -108,12 +144,51 @@ test('bad-formatted warning headers', t => { const res = Object.assign({}, mockFetchRes, { headers, }) - t.ok(checkResponse('get', res, 'registry', Date.now(), { - log: Object.assign({}, silentLog, { - warn (header, msg) { - t.fail('should not log warnings') - }, - }), + return t.resolves(checkResponse({ + method: 'get', + res, + registry, + startTime, + opts: { + log: Object.assign({}, silentLog, { + warn (header, msg) { + t.fail('should not log warnings') + }, + }), + }, })) - t.plan(1) +}) + +t.test('report auth for registry, but not for this request', t => { + const res = Object.assign({}, mockFetchRes, { + buffer: () => Promise.resolve(Buffer.from('ok')), + status: 400, + url: 'https://example.com/', + }) + t.plan(3) + t.rejects(checkResponse({ + method: 'get', + res, + uri: 'https://example.com/', + registry, + startTime, + auth: { + scopeAuthKey: '//some-scope-registry.com/', + auth: null, + token: null, + }, + opts: { + log: Object.assign({}, silentLog, { + warn (header, msg) { + t.equal(header, 'registry') + t.equal(msg, `No auth for URI, but auth present for scoped registry. + +URI: https://example.com/ +Scoped Registry Key: //some-scope-registry.com/ + +More info here: https://github.com/npm/cli/wiki/No-auth-for-URI,-but-auth-present-for-scoped-registry`) + }, + }), + }, + }), errors.HttpErrorGeneral) }) diff --git a/test/errors.js b/test/errors.js index f1f401e4..3e4a3376 100644 --- a/test/errors.js +++ b/test/errors.js @@ -2,7 +2,7 @@ const npa = require('npm-package-arg') const npmlog = require('npmlog') -const test = require('tap').test +const t = require('tap') const tnock = require('./util/tnock.js') const fetch = require('../index.js') @@ -20,7 +20,7 @@ const OPTS = { registry: 'https://mock.reg/', } -test('generic request errors', t => { +t.test('generic request errors', t => { tnock(t, OPTS.registry) .get('/ohno/oops') .reply(400, 'failwhale!') @@ -44,7 +44,7 @@ test('generic request errors', t => { ) }) -test('pkgid tie fighter', t => { +t.test('pkgid tie fighter', t => { tnock(t, OPTS.registry) .get('/-/ohno/_rewrite/ohyeah/maybe') .reply(400, 'failwhale!') @@ -57,7 +57,7 @@ test('pkgid tie fighter', t => { ) }) -test('pkgid _rewrite', t => { +t.test('pkgid _rewrite', t => { tnock(t, OPTS.registry) .get('/ohno/_rewrite/ohyeah/maybe') .reply(400, 'failwhale!') @@ -70,7 +70,7 @@ test('pkgid _rewrite', t => { ) }) -test('pkgid with `opts.spec`', t => { +t.test('pkgid with `opts.spec`', t => { tnock(t, OPTS.registry) .get('/ohno/_rewrite/ohyeah') .reply(400, 'failwhale!') @@ -86,7 +86,7 @@ test('pkgid with `opts.spec`', t => { ) }) -test('JSON error reporing', t => { +t.test('JSON error reporing', t => { tnock(t, OPTS.registry) .get('/ohno') .reply(400, { error: 'badarg' }) @@ -111,7 +111,7 @@ test('JSON error reporing', t => { ) }) -test('OTP error', t => { +t.test('OTP error', t => { tnock(t, OPTS.registry) .get('/otplease') .reply(401, { error: 'needs an otp, please' }, { @@ -128,7 +128,7 @@ test('OTP error', t => { ) }) -test('OTP error when missing www-authenticate', t => { +t.test('OTP error when missing www-authenticate', t => { tnock(t, OPTS.registry) .get('/otplease') .reply(401, { error: 'needs a one-time password' }) @@ -143,7 +143,7 @@ test('OTP error when missing www-authenticate', t => { ) }) -test('Bad IP address error', t => { +t.test('Bad IP address error', t => { tnock(t, OPTS.registry) .get('/badaddr') .reply(401, { error: 'you are using the wrong IP address, friend' }, { @@ -160,7 +160,7 @@ test('Bad IP address error', t => { ) }) -test('Unexpected www-authenticate error', t => { +t.test('Unexpected www-authenticate error', t => { tnock(t, OPTS.registry) .get('/unown') .reply(401, { @@ -185,4 +185,4 @@ test('Unexpected www-authenticate error', t => { ) }) -test('retries certain types') +t.test('retries certain types') diff --git a/test/index.js b/test/index.js index 14c20665..ce93e713 100644 --- a/test/index.js +++ b/test/index.js @@ -4,9 +4,16 @@ const Minipass = require('minipass') const npmlog = require('npmlog') const silentLog = require('../silentlog.js') const ssri = require('ssri') -const test = require('tap').test +const t = require('tap') const tnock = require('./util/tnock.js') const zlib = require('zlib') +const defaultOpts = require('../default-opts.js') + +t.equal(defaultOpts.registry, 'https://registry.npmjs.org/', + 'default registry is the npm public registry') + +// ok, now change it for the tests +defaultOpts.registry = 'https://mock.reg/' const fetch = require('../index.js') @@ -23,11 +30,10 @@ const OPTS = { minTimeout: 1, maxTimeout: 10, }, - registry: 'https://mock.reg/', } -test('hello world', t => { - tnock(t, OPTS.registry) +t.test('hello world', t => { + tnock(t, defaultOpts.registry) .get('/hello') .reply(200, { hello: 'world' }) return fetch('/hello', { @@ -38,18 +44,18 @@ test('hello world', t => { t.equal(res.status, 200, 'got successful response') return res.json() }) - .then(json => t.deepEqual(json, { hello: 'world' }, 'got correct body')) + .then(json => t.same(json, { hello: 'world' }, 'got correct body')) }) -test('JSON body param', t => { - tnock(t, OPTS.registry) +t.test('JSON body param', t => { + tnock(t, defaultOpts.registry) .matchHeader('content-type', ctype => { t.equal(ctype[0], 'application/json', 'content-type automatically set') return ctype[0] === 'application/json' }) .post('/hello') .reply(200, (uri, reqBody) => { - t.deepEqual(reqBody, { + t.same(reqBody, { hello: 'world', }, 'got the JSON version of the body') return reqBody @@ -64,18 +70,18 @@ test('JSON body param', t => { t.equal(res.status, 200) return res.json() }) - .then(json => t.deepEqual(json, { hello: 'world' })) + .then(json => t.same(json, { hello: 'world' })) }) -test('buffer body param', t => { - tnock(t, OPTS.registry) +t.test('buffer body param', t => { + tnock(t, defaultOpts.registry) .matchHeader('content-type', ctype => { t.equal(ctype[0], 'application/octet-stream', 'content-type automatically set') return ctype[0] === 'application/octet-stream' }) .post('/hello') .reply(200, (uri, reqBody) => { - t.deepEqual( + t.same( Buffer.from(reqBody, 'utf8'), Buffer.from('hello', 'utf8'), 'got the JSON version of the body' @@ -93,19 +99,19 @@ test('buffer body param', t => { return res.buffer() }) .then(buf => - t.deepEqual(buf, Buffer.from('hello', 'utf8'), 'got response') + t.same(buf, Buffer.from('hello', 'utf8'), 'got response') ) }) -test('stream body param', t => { - tnock(t, OPTS.registry) +t.test('stream body param', t => { + tnock(t, defaultOpts.registry) .matchHeader('content-type', ctype => { t.equal(ctype[0], 'application/octet-stream', 'content-type automatically set') return ctype[0] === 'application/octet-stream' }) .post('/hello') .reply(200, (uri, reqBody) => { - t.deepEqual(JSON.parse(reqBody), { + t.same(JSON.parse(reqBody), { hello: 'world', }, 'got the stringified version of the body') return reqBody @@ -122,11 +128,11 @@ test('stream body param', t => { t.equal(res.status, 200) return res.json() }) - .then(json => t.deepEqual(json, { hello: 'world' })) + .then(json => t.same(json, { hello: 'world' })) }) -test('JSON body param', t => { - tnock(t, OPTS.registry) +t.test('JSON body param', t => { + tnock(t, defaultOpts.registry) .matchHeader('content-type', ctype => { t.equal(ctype[0], 'application/json', 'content-type automatically set') return ctype[0] === 'application/json' @@ -150,8 +156,8 @@ test('JSON body param', t => { }) }) -test('gzip + buffer body param', t => { - tnock(t, OPTS.registry) +t.test('gzip + buffer body param', t => { + tnock(t, defaultOpts.registry) .matchHeader('content-type', ctype => { t.equal(ctype[0], 'application/octet-stream', 'content-type automatically set') return ctype[0] === 'application/octet-stream' @@ -163,7 +169,7 @@ test('gzip + buffer body param', t => { .post('/hello') .reply(200, (uri, reqBody) => { reqBody = zlib.gunzipSync(Buffer.from(reqBody, 'hex')) - t.deepEqual( + t.same( Buffer.from(reqBody, 'utf8').toString('utf8'), 'hello', 'got the JSON version of the body' @@ -182,12 +188,12 @@ test('gzip + buffer body param', t => { return res.buffer() }) .then(buf => - t.deepEqual(buf, Buffer.from('hello', 'utf8'), 'got response') + t.same(buf, Buffer.from('hello', 'utf8'), 'got response') ) }) -test('gzip + stream body param', t => { - tnock(t, OPTS.registry) +t.test('gzip + stream body param', t => { + tnock(t, defaultOpts.registry) .matchHeader('content-type', ctype => { t.equal(ctype[0], 'application/octet-stream', 'content-type automatically set') return ctype[0] === 'application/octet-stream' @@ -199,7 +205,7 @@ test('gzip + stream body param', t => { .post('/hello') .reply(200, (uri, reqBody) => { reqBody = zlib.gunzipSync(Buffer.from(reqBody, 'hex')) - t.deepEqual(JSON.parse(reqBody.toString('utf8')), { + t.same(JSON.parse(reqBody.toString('utf8')), { hello: 'world', }, 'got the stringified version of the body') return reqBody @@ -221,11 +227,11 @@ test('gzip + stream body param', t => { t.equal(res.status, 200) return res.json() }) - .then(json => t.deepEqual(json, { hello: 'world' })) + .then(json => t.same(json, { hello: 'world' })) }) -test('query strings', t => { - tnock(t, OPTS.registry) +t.test('query strings', t => { + tnock(t, defaultOpts.registry) .get('/hello?hi=there&who=wor%20ld') .reply(200, { hello: 'world' }) return fetch.json('/hello?hi=there', { @@ -234,8 +240,8 @@ test('query strings', t => { }).then(json => t.equal(json.hello, 'world', 'query-string merged')) }) -test('query strings - undefined values', t => { - tnock(t, OPTS.registry) +t.test('query strings - undefined values', t => { + tnock(t, defaultOpts.registry) .get('/hello?who=wor%20ld') .reply(200, { ok: true }) return fetch.json('/hello', { @@ -244,20 +250,20 @@ test('query strings - undefined values', t => { }).then(json => t.ok(json.ok, 'undefined keys not included in query string')) }) -test('json()', t => { - tnock(t, OPTS.registry) +t.test('json()', t => { + tnock(t, defaultOpts.registry) .get('/hello') .reply(200, { hello: 'world' }) return fetch.json('/hello', OPTS) - .then(json => t.deepEqual(json, { hello: 'world' }, 'got json body')) + .then(json => t.same(json, { hello: 'world' }, 'got json body')) }) -test('query string with ?write=true', t => { +t.test('query string with ?write=true', t => { const cache = t.testdir() const opts = { ...OPTS, preferOffline: true, cache } const qsString = { ...opts, query: { write: 'true' } } const qsBool = { ...opts, query: { write: true } } - tnock(t, opts.registry) + tnock(t, defaultOpts.registry) .get('/writeTrueTest?write=true') .times(6) .reply(200, { write: 'go for it' }) @@ -276,14 +282,14 @@ test('query string with ?write=true', t => { .then(res => t.strictSame(res, { write: 'go for it' })) }) -test('fetch.json.stream()', t => { - tnock(t, OPTS.registry).get('/hello').reply(200, { +t.test('fetch.json.stream()', t => { + tnock(t, defaultOpts.registry).get('/hello').reply(200, { a: 1, b: 2, c: 3, }) return fetch.json.stream('/hello', '$*', OPTS).collect().then(data => { - t.deepEqual(data, [ + t.same(data, [ { key: 'a', value: 1 }, { key: 'b', value: 2 }, { key: 'c', value: 3 }, @@ -291,8 +297,8 @@ test('fetch.json.stream()', t => { }) }) -test('fetch.json.stream opts.mapJSON', t => { - tnock(t, OPTS.registry).get('/hello').reply(200, { +t.test('fetch.json.stream opts.mapJSON', t => { + tnock(t, defaultOpts.registry).get('/hello').reply(200, { a: 1, b: 2, c: 3, @@ -303,7 +309,7 @@ test('fetch.json.stream opts.mapJSON', t => { return [key, value] }, }).collect().then(data => { - t.deepEqual(data, [ + t.same(data, [ ['a', 1], ['b', 2], ['c', 3], @@ -311,7 +317,7 @@ test('fetch.json.stream opts.mapJSON', t => { }) }) -test('fetch.json.stream gets fetch error on stream', t => { +t.test('fetch.json.stream gets fetch error on stream', t => { return t.rejects(fetch.json.stream('/hello', '*', { ...OPTS, body: Promise.reject(new Error('no body for you')), @@ -322,8 +328,8 @@ test('fetch.json.stream gets fetch error on stream', t => { }) }) -test('opts.ignoreBody', t => { - tnock(t, OPTS.registry) +t.test('opts.ignoreBody', t => { + tnock(t, defaultOpts.registry) .get('/hello') .reply(200, { hello: 'world' }) return fetch('/hello', { ...OPTS, ignoreBody: true }) @@ -332,8 +338,8 @@ test('opts.ignoreBody', t => { }) }) -test('method configurable', t => { - tnock(t, OPTS.registry) +t.test('method configurable', t => { + tnock(t, defaultOpts.registry) .delete('/hello') .reply(200) const opts = { @@ -346,8 +352,8 @@ test('method configurable', t => { }) }) -test('npm-notice header logging', t => { - tnock(t, OPTS.registry) +t.test('npm-notice header logging', t => { + tnock(t, defaultOpts.registry) .get('/hello') .reply(200, { hello: 'world' }, { 'npm-notice': 'npm <3 u', @@ -366,9 +372,9 @@ test('npm-notice header logging', t => { .then(res => t.equal(res.status, 200, 'got successful response')) }) -test('optionally verifies request body integrity', t => { +t.test('optionally verifies request body integrity', t => { t.plan(3) - tnock(t, OPTS.registry) + tnock(t, defaultOpts.registry) .get('/hello') .times(2) .reply(200, 'hello') @@ -394,9 +400,9 @@ test('optionally verifies request body integrity', t => { }) }) -test('pickRegistry() utility', t => { +t.test('pickRegistry() utility', t => { const pick = fetch.pickRegistry - t.equal(pick('foo@1.2.3'), 'https://registry.npmjs.org/', 'has good default') + t.equal(pick('foo@1.2.3'), defaultOpts.registry, 'has good default') t.equal( pick('foo@1.2.3', { registry: 'https://my.registry/here/', @@ -424,13 +430,13 @@ test('pickRegistry() utility', t => { 'https://my.scoped.registry/here/', 'scope @ is option@l' ) - t.done() + t.end() }) -test('pickRegistry through opts.spec', t => { - tnock(t, OPTS.registry) +t.test('pickRegistry through opts.spec', t => { + tnock(t, defaultOpts.registry) .get('/pkg') - .reply(200, { source: OPTS.registry }) + .reply(200, { source: defaultOpts.registry }) const scopedReg = 'https://scoped.mock.reg/' tnock(t, scopedReg) .get('/pkg') @@ -442,7 +448,7 @@ test('pickRegistry through opts.spec', t => { '@myscope:registry': scopedReg, }).then(json => t.equal( json.source, - OPTS.registry, + defaultOpts.registry, 'request made to main registry' )).then(() => fetch.json('/pkg', { ...OPTS, @@ -463,8 +469,8 @@ test('pickRegistry through opts.spec', t => { )) }) -test('log warning header info', t => { - tnock(t, OPTS.registry) +t.test('log warning header info', t => { + tnock(t, defaultOpts.registry) .get('/hello') .reply(200, { hello: 'world' }, { Warning: '199 - "ENOTFOUND" "Wed, 21 Oct 2015 07:28:00 GMT"' }) const opts = { @@ -472,7 +478,7 @@ test('log warning header info', t => { log: Object.assign({}, silentLog, { warn (header, msg) { t.equal(header, 'registry', 'expected warn log header') - t.equal(msg, `Using stale data from ${OPTS.registry} because the host is inaccessible -- are you offline?`, 'logged out at WARNING level') + t.equal(msg, `Using stale data from ${defaultOpts.registry} because the host is inaccessible -- are you offline?`, 'logged out at WARNING level') }, }), } @@ -481,13 +487,13 @@ test('log warning header info', t => { .then(res => t.equal(res.status, 200, 'got successful response')) }) -test('npm-in-ci header with forced CI=false', t => { +t.test('npm-in-ci header with forced CI=false', t => { const CI = process.env.CI process.env.CI = false t.teardown(t => { process.env.CI = CI }) - tnock(t, OPTS.registry) + tnock(t, defaultOpts.registry) .get('/hello') .reply(200, { hello: 'world' }) return fetch('/hello', OPTS) @@ -496,8 +502,8 @@ test('npm-in-ci header with forced CI=false', t => { }) }) -test('miscellaneous headers', t => { - tnock(t, OPTS.registry) +t.test('miscellaneous headers', t => { + tnock(t, defaultOpts.registry) .matchHeader('npm-session', session => t.strictSame(session, ['foobarbaz'], 'session set from options')) .matchHeader('npm-scope', scope => @@ -513,6 +519,7 @@ test('miscellaneous headers', t => { return fetch('/hello', { ...OPTS, + registry: null, // always falls back on falsey registry value npmSession: 'foobarbaz', projectScope: '@foo', userAgent: 'agent of use',