Files
pnpm/pnpm11/network/web-auth/test/formatAuthUrlMessage.test.ts
T
John-David Dalton b9e7b8d88d feat(login): support web login without an interactive terminal (#13479)
pnpm login refused to run whenever stdin or stdout was not a TTY, even
though the registry web-auth flow only prints an authentication URL and
polls the done endpoint until the browser approval completes - neither
needs a terminal. Agent- and CI-adjacent tooling had to wrap pnpm in a
pseudo-terminal (script -q /dev/null pnpm login) to use the web flow.

Move the non-interactive guard from the top of the login command into
the classic username/password fallback, the only path that prompts on
the terminal. Without a TTY the web flow now prints the authentication
URL and polls as before; the URL is printed without the QR code (a
piped stdout cannot render the block art), and the press-ENTER browser
prompt was already skipped for a non-TTY stdin. A registry without web
login support still fails with ERR_PNPM_LOGIN_NON_INTERACTIVE.

Harden the TypeScript web-login path to match pacquet while touching
it: narrow the attacker-controlled response body at runtime (a missing,
empty, or non-string loginUrl/doneUrl is an invalid response), and
reject URLs containing Unicode control characters with pacquet's
ERR_PNPM_AUTH_COMMANDS_LOGIN_UNSAFE_URL before anything is printed or
polled. The shared error message now says "authentication URL" in both
stacks, since the check covers loginUrl and doneUrl alike.

Implemented in both stacks: the TypeScript CLI moves the guard into
classicLogin and prints a URL-only message via the new
formatAuthUrlOnlyMessage export of the web-auth package; pacquet moves
the same guard into classic_login and selects AuthUrlMessage::UrlOnly
when stdout is not a TTY. The pacquet CLI adapter unit test and the
CLI-tier integration tests now drive the guard through a 404 web-login
probe so it exercises the classic fallback, and a new integration test
covers the headless web flow end-to-end against a mock registry.

Also acknowledge a pre-existing zizmor ref-version-mismatch finding on
the winget-releaser pin in update-latest.yml with the repository's
usual inline ignore: the pinned commit is no longer reachable from any
named ref upstream, so no version comment can describe it accurately.
2026-07-29 12:12:10 +02:00

30 lines
1.3 KiB
TypeScript

import { describe, expect, it } from '@jest/globals'
import { formatAuthUrlMessage, formatAuthUrlOnlyMessage, generateQrCode } from '@pnpm/network.web-auth'
describe('formatAuthUrlMessage', () => {
it('appends a QR code when one can be generated', () => {
const authUrl = 'https://example.com/auth'
const message = formatAuthUrlMessage(authUrl, msg => {
throw new Error(`Unexpected call to globalWarn: ${msg}`)
})
expect(message).toBe(`Authenticate your account at:\n${authUrl}\n\n${generateQrCode(authUrl)}`)
})
it('warns and falls back to a URL-only message when QR generation fails', () => {
// Longer than the 2953-byte maximum QR data capacity (version 40 at
// error-correction level L), which makes qrcode-terminal throw.
const longAuthUrl = `https://example.com/auth/${'a'.repeat(4000)}`
const warnings: string[] = []
const message = formatAuthUrlMessage(longAuthUrl, msg => warnings.push(msg))
expect(message).toBe(`Authenticate your account at:\n${longAuthUrl}`)
expect(warnings).toStrictEqual([expect.stringContaining('Could not generate a QR code:')])
})
})
describe('formatAuthUrlOnlyMessage', () => {
it('formats the message without a QR code', () => {
const authUrl = 'https://example.com/auth'
expect(formatAuthUrlOnlyMessage(authUrl)).toBe(`Authenticate your account at:\n${authUrl}`)
})
})