Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ const login = async (npm, { creds, ...opts }) => {

// auth type !== web or ENYI error w/ web login
if (!res) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The predicate matches the precedent in otplease() at line 10 and openUrlPrompt() in lib/utils/open-url.js exactly, and the placement inside if (!res) is right — it covers both --auth-type=legacy and the web -> ENYI -> couch fallback, which are the two repros in #9860, while leaving --auth-type=web alone. No objection to the guard itself.

The blocker is fallout: test/lib/commands/login.js mocks process.stdin/process.stdout as stream.PassThrough with { replace: true } and never sets isTTY, so this guard fires in legacy > basic login, legacy > scoped login default registry, legacy > scoped login scoped registry and web > fallback. All four fail with code: ENOTTY and the file aborts. Reverting just this hunk makes the file green again, so it is this change.

Fix is in the test fixture, not here: mockLogin() should add isTTY: true to the replaced stdin/stdout globals (a PassThrough happily takes the property), matching how the otplease tests in test/lib/utils/auth.js already pass { isTTY: true }. Please run tap test/lib/commands/login.js as well as test/lib/utils/auth.js before pushing.

throw Object.assign(new Error(
'npm login requires an interactive terminal to prompt for credentials.'
), { code: 'ENOTTYAUTH' })
}

const username = await read.username('Username:', creds.username)
const password = await read.password('Password:', creds.password)
res = await otplease(npm, opts, (reqOpts) => loginCouch(username, password, reqOpts))
Expand Down
9 changes: 9 additions & 0 deletions lib/utils/error-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,15 @@ const errorMessage = (er, npm) => {
detail.push(['need auth', 'You need to authorize this machine using `npm login`'])
break

case 'ENOTTYAUTH':
summary.push(['need auth', er.message])
detail.push(['need auth', [
'Create a granular access token at https://www.npmjs.com/, then set it with:',
' npm config set //registry.npmjs.org/:_authToken=<token>',
'or by setting the NPM_TOKEN environment variable.',
].join('\n')])
break

case 'ECONNRESET':
case 'ENOTFOUND':
case 'ETIMEDOUT':
Expand Down
83 changes: 52 additions & 31 deletions tap-snapshots/test/lib/utils/error-message.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,28 @@ Object {
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 13`] = `
Object {
"detail": Array [
Array [
"network",
String(
This is a problem related to network connectivity.
In most cases you are behind a proxy or have bad network settings.

If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config'
),
],
],
"summary": Array [
Array [
"network",
"foo",
],
],
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 14`] = `
Object {
"detail": Array [
Array [
Expand All @@ -1212,7 +1234,7 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 14`] = `
exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 15`] = `
Object {
"detail": Array [
Array [
Expand All @@ -1232,7 +1254,7 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 15`] = `
exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 16`] = `
Object {
"detail": Array [
Array [
Expand All @@ -1249,7 +1271,7 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 16`] = `
exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 17`] = `
Object {
"detail": Array [
Array [
Expand All @@ -1270,26 +1292,6 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 17`] = `
Object {
"detail": Array [
Array [
"typeerror",
String(
This is an error with npm itself. Please report this error at:
https://github.com/npm/cli/issues
),
],
],
"summary": Array [
Array [
"typeerror",
"dummy stack trace",
],
],
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 18`] = `
Object {
"detail": Array [
Expand Down Expand Up @@ -1372,6 +1374,26 @@ Object {
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 21`] = `
Object {
"detail": Array [
Array [
"typeerror",
String(
This is an error with npm itself. Please report this error at:
https://github.com/npm/cli/issues
),
],
],
"summary": Array [
Array [
"typeerror",
"dummy stack trace",
],
],
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 22`] = `
Object {
"detail": Array [
Array [
Expand All @@ -1388,7 +1410,7 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 22`] = `
exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 23`] = `
Object {
"detail": Array [
Array [
Expand All @@ -1405,7 +1427,7 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 23`] = `
exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 24`] = `
Object {
"detail": Array [
Array [
Expand Down Expand Up @@ -1569,18 +1591,17 @@ exports[`test/lib/utils/error-message.js TAP just simple messages > must match s
Object {
"detail": Array [
Array [
"network",
"need auth",
String(
This is a problem related to network connectivity.
In most cases you are behind a proxy or have bad network settings.

If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config'
Create a granular access token at https://www.npmjs.com/, then set it with:
npm config set //registry.npmjs.org/:_authToken=<token>
or by setting the NPM_TOKEN environment variable.
),
],
],
"summary": Array [
Array [
"network",
"need auth",
"foo",
],
],
Expand Down
5 changes: 4 additions & 1 deletion test/lib/commands/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ const mockLogin = async (t, { stdin: stdinLines, registry: registryUrl, ...optio
let stdin
if (stdinLines) {
stdin = new stream.PassThrough()
stdin.isTTY = true
for (const l of stdinLines) {
stdin.write(l + '\n')
}
const stdout = new stream.PassThrough() // to quiet readline
stdout.isTTY = true
mockGlobals(t, {
'process.stdin': stdin,
'process.stdout': new stream.PassThrough(), // to quiet readline
'process.stdout': stdout,
}, { replace: true })
}
const mock = await loadMockNpm(t, {
Expand Down
105 changes: 105 additions & 0 deletions test/lib/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,108 @@ t.test('does not prompt if stdin or stdout is not a tty', async (t) => {
},
}, fn), { message: 'nope' }, 'rejects with the original error')
})

const setupLogin = async (t, { creds = {}, loginWeb, ...rest }, opts = {}) => {
const { login } = tmock(t, '{LIB}/utils/auth.js', {
'{LIB}/utils/read-user-info.js': {
username: async () => 'foo',
password: async () => 'bar',
},
'{LIB}/utils/open-url.js': {
createOpener: () => () => {},
},
'npm-profile': {
loginCouch: async () => ({ token: 'test-token' }),
...(loginWeb ? { loginWeb } : {}),
},
})
const { npm } = await setupMockNpm(t, {
...rest,
config: { 'auth-type': 'legacy', ...rest.config },

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forcing 'auth-type': 'legacy' here is what makes the helper work, but it also means this helper structurally cannot test the branch #9860 actually reports as repro #2 — web login, registry returns 4xx, npm-profile maps it to ENYI, npm falls back to couch and hits the prompt.

It is not just the config: the npm-profile mock only stubs loginCouch, so loginWeb and webAuthOpener are undefined, and {LIB}/utils/open-url.js is not mocked at all. Set auth-type to web and you get loginWeb is not a function, not an ENYI fallback — so the guard's most important real-world entry point is silently untested and any future regression in the fallback branch would not be caught here.

Worth extending the helper rather than leaving the gap:

'{LIB}/utils/open-url.js': { createOpener: () => () => {} },
'npm-profile': {
  loginWeb: async () => { throw Object.assign(new Error('nyi'), { code: 'ENYI' }) },
  loginCouch: async () => ({ token: 'test-token' }),
},

plus a case with config: { 'auth-type': 'web' } and non-TTY globals asserting ENOTTY, and one asserting a non-ENYI loginWeb error still rethrows untouched rather than being swallowed into the TTY message.

(For what it is worth, test/lib/commands/login.js already has a web > fallback integration test covering that path — it is one of the four this PR breaks. Fixing its isTTY mocks is the higher-priority half of this.)

})
return login(npm, { creds, registry: 'https://registry.npmjs.org/', ...opts })
}

t.test('login throws a clear error when stdin is not a tty', async (t) => {
await t.rejects(setupLogin(t, {
globals: {
'process.stdin': { isTTY: false },
'process.stdout': { isTTY: true },
},
}), {
code: 'ENOTTYAUTH',
message: /requires an interactive terminal/,
}, 'rejects with a clear, actionable error instead of hanging')
})

t.test('login throws a clear error when stdout is not a tty', async (t) => {
await t.rejects(setupLogin(t, {
globals: {
'process.stdin': { isTTY: true },
'process.stdout': { isTTY: false },
},
}), {
code: 'ENOTTYAUTH',
message: /requires an interactive terminal/,
}, 'rejects with a clear, actionable error instead of hanging')
})

t.test('login throws a clear error when neither stdin nor stdout is a tty', async (t) => {
await t.rejects(setupLogin(t, {
globals: {
'process.stdin': { isTTY: false },
'process.stdout': { isTTY: false },
},
}), {
code: 'ENOTTYAUTH',
message: /requires an interactive terminal/,
}, 'rejects with a clear, actionable error instead of hanging')
})

t.test('login succeeds with couch when stdin and stdout are ttys', async (t) => {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that a positive-path regression guard is included — asserting that a real TTY still logs in is the thing that stops a guard like this from quietly disabling the feature.

One gap: there is no case for stdin and stdout both non-TTY. The two negative tests each flip only one flag, so a future refactor that changed || to && would still pass both of them. The existing does not prompt if stdin or stdout is not a tty test above sets both to false; mirroring that here would close it.

const result = await setupLogin(t, {
globals: {
'process.stdin': { isTTY: true },
'process.stdout': { isTTY: true },
},
})

t.strictSame(result, {
message: 'Logged in on https://registry.npmjs.org/.',
newCreds: { token: 'test-token' },
})
})

t.test('login throws a clear error for the web login ENYI fallback when not a tty', async (t) => {
await t.rejects(setupLogin(t, {
config: { 'auth-type': 'web' },
loginWeb: async () => {
throw Object.assign(new Error('web login not supported'), { code: 'ENYI' })
},
globals: {
'process.stdin': { isTTY: false },
'process.stdout': { isTTY: false },
},
}), {
code: 'ENOTTYAUTH',
message: /requires an interactive terminal/,
}, 'rejects with a clear, actionable error instead of hanging on the couch fallback')
})

t.test('login falls back to couch after web login ENYI when a tty', async (t) => {
const result = await setupLogin(t, {
config: { 'auth-type': 'web' },
loginWeb: async () => {
throw Object.assign(new Error('web login not supported'), { code: 'ENYI' })
},
globals: {
'process.stdin': { isTTY: true },
'process.stdout': { isTTY: true },
},
})

t.strictSame(result, {
message: 'Logged in on https://registry.npmjs.org/.',
newCreds: { token: 'test-token' },
})
})
1 change: 1 addition & 0 deletions test/lib/utils/error-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ t.test('just simple messages', async t => {
'EISGIT',
'EEXIST',
'ENEEDAUTH',
'ENOTTYAUTH',
'ECONNRESET',
'ENOTFOUND',
'ETIMEDOUT',
Expand Down